Skip to content

Creating a Plugin

This guide walks you through building a platform plugin for unified-live. A plugin connects a streaming platform’s API to the SDK’s unified interface.

Create a plugin when you want to add support for a new streaming platform (e.g., Kick, Bilibili, Nico Nico). Each plugin is an independent package that maps one platform’s API to the unified Content, Channel, and Broadcast types.

A plugin consists of two parts:

  1. PluginDefinition — Declarative configuration: name, base URL, auth, rate limiting, URL matching
  2. PluginMethods — Data access functions that use RestManager to call the platform API

These are combined with PlatformPlugin.create(definition, methods) to produce a fully wired plugin.

PluginDefinition + PluginMethods
PlatformPlugin.create()
PlatformPlugin (ready to register with UnifiedClient)

The matchUrl function is a pure function (no network calls) that detects whether a URL belongs to your platform:

import type { ResolvedUrl } from "@unified-live/core";
const matchExampleUrl = (url: string): ResolvedUrl | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "example.tv") return null;
// Match: https://example.tv/videos/12345
const videoMatch = parsed.pathname.match(/^\/videos\/(\w+)$/);
if (videoMatch) {
return { platform: "example", type: "content", id: videoMatch[1] };
}
// Match: https://example.tv/channels/username
const channelMatch = parsed.pathname.match(/^\/channels\/(\w+)$/);
if (channelMatch) {
return { platform: "example", type: "channel", id: channelMatch[1] };
}
return null;
} catch {
return null;
}
};

Define your PluginDefinition with all platform-specific settings. This will be assembled inside a factory function (see Step 4):

import {
TokenManager,
createTokenBucketStrategy,
createRateLimitHeaderParser,
type PluginDefinition,
} from "@unified-live/core";
const parseHeaders = createRateLimitHeaderParser({
limit: "X-RateLimit-Limit",
remaining: "X-RateLimit-Remaining",
reset: "X-RateLimit-Reset",
});
const createDefinition = (config: {
apiKey: string;
fetch?: typeof globalThis.fetch;
}): PluginDefinition => ({
name: "example",
baseUrl: "https://api.example.tv/v1",
rateLimitStrategy: createTokenBucketStrategy({
global: { requests: 100, perMs: 60_000 }, // 100 req/min
parseHeaders,
platform: "example",
}),
tokenManager: TokenManager.static(`Bearer ${config.apiKey}`),
matchUrl: matchExampleUrl,
fetch: config.fetch,
capabilities: {
supportsBroadcasts: true,
supportsArchiveResolution: false,
supportsBatchContent: false,
supportsBatchBroadcasts: false,
supportsSearch: false,
supportsClips: false,
authModel: "apiKey",
rateLimitModel: "tokenBucket",
},
});

Implement PluginMethods — each method receives a RestManager and returns unified types:

import type {
RestManager,
PluginMethods,
Content,
Channel,
Broadcast,
Page,
Archive,
} from "@unified-live/core";
const exampleGetContent = async (rest: RestManager, id: string): Promise<Content> => {
const res = await rest.request<{ video: ExampleVideo }>({
method: "GET",
path: `/videos/${id}`,
bucketId: "videos:get",
});
return mapToContent(res.data.video);
};
const exampleGetChannel = async (rest: RestManager, id: string): Promise<Channel> => {
const res = await rest.request<{ channel: ExampleChannel }>({
method: "GET",
path: `/channels/${id}`,
bucketId: "channels:get",
});
return mapToChannel(res.data.channel);
};
const exampleListBroadcasts = async (
rest: RestManager,
channelId: string,
): Promise<Broadcast[]> => {
const res = await rest.request<{ streams: ExampleStream[] }>({
method: "GET",
path: `/channels/${channelId}/live`,
bucketId: "streams:list",
});
return res.data.streams.map(mapToBroadcast);
};
const exampleListArchives = async (
rest: RestManager,
channelId: string,
cursor?: string,
pageSize?: number,
options?: ArchiveListOptions,
): Promise<Page<Archive>> => {
const res = await rest.request<{ videos: ExampleVideo[]; nextCursor?: string }>({
method: "GET",
path: `/channels/${channelId}/videos`,
query: {
...(cursor && { cursor }),
...(pageSize && { limit: String(pageSize) }),
},
bucketId: "videos:list",
});
return {
items: res.data.videos.map(mapToArchive),
cursor: res.data.nextCursor,
hasMore: !!res.data.nextCursor,
};
};
const methods: PluginMethods = {
getContent: exampleGetContent,
getChannel: exampleGetChannel,
listBroadcasts: exampleListBroadcasts,
listArchives: exampleListArchives,
};

Combine definition and methods with PlatformPlugin.create():

export const createExamplePlugin = (config: {
apiKey: string;
fetch?: typeof globalThis.fetch;
}): PlatformPlugin => {
return PlatformPlugin.create(createDefinition(config), methods);
};

The SDK supports three auth patterns via TokenManager:

PatternUse CaseExample
StaticAPI key / Basic authTokenManager.static("Bearer key123")
OAuth2Token refresh neededImplement getAuthHeader() + invalidate() (see below)
Query paramAPI key in URLUse transformRequest instead of tokenManager

For query parameter auth (like YouTube), use transformRequest:

const definition: PluginDefinition = {
// ...
transformRequest: (req) => ({
...req,
query: { ...req.query, key: config.apiKey },
}),
};

For OAuth2, implement a custom TokenManager:

import { AuthenticationError, type TokenManager } from "@unified-live/core";
const createOAuth2TokenManager = (config: {
clientId: string;
clientSecret: string;
}): TokenManager => {
let token: string | null = null;
let expiresAt = 0;
return {
getAuthHeader: async () => {
if (!token || Date.now() > expiresAt) {
const res = await fetch("https://api.example.tv/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: config.clientId,
client_secret: config.clientSecret,
}).toString(),
});
if (!res.ok)
throw new AuthenticationError("example", {
message: `Token endpoint returned ${res.status}`,
});
const data = await res.json();
token = data.access_token;
expiresAt = Date.now() + data.expires_in * 1000 * 0.9; // refresh at 90%
}
return `Bearer ${token}`;
},
invalidate: () => {
token = null;
expiresAt = 0;
},
};
};

Choose a strategy based on the platform’s model:

StrategyWhen to UseExample Platform
Token BucketFixed requests per time windowTwitch (800 req/min), TwitCasting (60 req/min)
Quota BudgetCost-based daily limitYouTube (10,000 units/day)

Token Bucket — for platforms with request-per-second limits:

import { createTokenBucketStrategy } from "@unified-live/core";
const strategy = createTokenBucketStrategy({
global: { requests: 100, perMs: 60_000 },
parseHeaders: myHeaderParser,
platform: "myplatform",
});

Quota Budget — for platforms with daily cost-based limits:

import { createQuotaBudgetStrategy } from "@unified-live/core";
const strategy = createQuotaBudgetStrategy({
dailyLimit: 10_000,
costMap: {
"videos:get": 1,
"channels:get": 1,
"search:list": 100,
},
defaultCost: 1,
platform: "example",
});
import { describe, it, expect } from "vitest";
describe("matchExampleUrl", () => {
it.each([
["https://example.tv/videos/123", { platform: "example", type: "content", id: "123" }],
["https://example.tv/channels/user1", { platform: "example", type: "channel", id: "user1" }],
["https://other.com/videos/123", null],
["not-a-url", null],
])("matchExampleUrl(%s) = %o", (url, expected) => {
expect(matchExampleUrl(url)).toEqual(expected);
});
});

Use a mock fetch to test data methods without hitting real APIs:

import { describe, it, expect, vi } from "vitest";
describe("createExamplePlugin", () => {
it("fetches content by ID", async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ video: { id: "123", title: "Test" } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
const plugin = createExamplePlugin({
apiKey: "test-key",
fetch: mockFetch, // inject mock fetch
});
const content = await plugin.getContent("123");
expect(content.title).toBe("Test");
});
});

Here’s a minimal but complete plugin:

import {
PlatformPlugin,
TokenManager,
createTokenBucketStrategy,
type PluginDefinition,
type PluginMethods,
type RestManager,
type Content,
type Channel,
type Broadcast,
type Page,
type Archive,
type ResolvedUrl,
} from "@unified-live/core";
// URL matching
const matchUrl = (url: string): ResolvedUrl | null => {
try {
const parsed = new URL(url);
if (parsed.hostname !== "example.tv") return null;
const match = parsed.pathname.match(/^\/videos\/(\w+)$/);
return match ? { platform: "example", type: "content", id: match[1] } : null;
} catch {
return null;
}
};
// Data methods
const getContent = async (rest: RestManager, id: string): Promise<Content> => {
const res = await rest.request<any>({ method: "GET", path: `/videos/${id}` });
return res.data as Content; // map platform response to Content
};
const getChannel = async (rest: RestManager, id: string): Promise<Channel> => {
const res = await rest.request<any>({ method: "GET", path: `/channels/${id}` });
return res.data as Channel; // map platform response to Channel
};
const listBroadcasts = async (rest: RestManager, channelId: string): Promise<Broadcast[]> => {
const res = await rest.request<any>({ method: "GET", path: `/channels/${channelId}/live` });
return []; // map res.data to Broadcast[]
};
const listArchives = async (
rest: RestManager,
channelId: string,
cursor?: string,
pageSize?: number,
options?: ArchiveListOptions,
): Promise<Page<Archive>> => {
const res = await rest.request<any>({ method: "GET", path: `/channels/${channelId}/videos` });
return { items: [], hasMore: false }; // map res.data
};
// Factory
export type ExamplePluginConfig = { apiKey: string; fetch?: typeof globalThis.fetch };
export const createExamplePlugin = (config: ExamplePluginConfig): PlatformPlugin => {
return PlatformPlugin.create(
{
name: "example",
baseUrl: "https://api.example.tv/v1",
rateLimitStrategy: createTokenBucketStrategy({
global: { requests: 100, perMs: 60_000 },
parseHeaders: () => undefined,
platform: "example",
}),
tokenManager: TokenManager.static(`Bearer ${config.apiKey}`),
matchUrl,
fetch: config.fetch,
capabilities: {
supportsBroadcasts: true,
supportsArchiveResolution: false,
supportsBatchContent: false,
supportsBatchBroadcasts: false,
supportsSearch: false,
supportsClips: false,
authModel: "apiKey",
rateLimitModel: "tokenBucket",
},
},
{ getContent, getChannel, listBroadcasts, listArchives },
);
};