Host pattern
Every feature area follows the same three-layer structure:
- Host type - the contract (SDK, no implementation)
- API class - what plugins actually call (SDK, wraps the host)
- Host implementation - bridges the API to player internals (player)
Plugins call methods on an API class (e.g. api.Queue.addToQueue()), which holds a reference to the host and delegates to it. Plugins never touch a host directly.
Files to create/modify
SDK (packages/plugin-sdk/)
| Action | File | What |
|---|---|---|
| Create | src/types/yourDomain.ts |
YourDomainHost interface + related types |
| Create | src/api/yourDomain.ts |
YourDomainAPI class |
| Modify | src/api/index.ts |
Add yourDomainHost option + YourDomain field to NuclearAPI |
| Modify | src/index.ts |
Export types and API class |
Player (packages/player/)
| Action | File | What |
|---|---|---|
| Create | src/services/yourDomainHost.ts |
Host implementation + singleton export |
| Modify | src/services/plugins/createPluginAPI.ts |
Pass singleton to NuclearPluginAPI |
| Modify | src/services/logger.ts |
Add domain to LOG_SCOPES (needed for reportError) |
If the domain needs shared model types: create packages/model/src/yourDomain.ts and re-export from packages/model/src/index.ts.
API class pattern
Every API class follows this structure.
// packages/plugin-sdk/src/api/yourDomain.ts
export class YourDomainAPI {
#host?: YourDomainHost;
constructor(host?: YourDomainHost) {
this.#host = host;
}
#withHost<T>(fn: (host: YourDomainHost) => T): T {
const host = this.#host;
if (!host) {
throw new Error('YourDomain host not available');
}
return fn(host);
}
yourMethod(arg: SomeType) {
return this.#withHost((host) => host.yourMethod(arg));
}
}
Reference: packages/plugin-sdk/src/api/queue.ts, packages/plugin-sdk/src/api/dashboard.ts
Connecting to NuclearAPI
// packages/plugin-sdk/src/api/index.ts
import type { YourDomainHost } from '../types/yourDomain';
import { YourDomainAPI } from './yourDomain';
// Add to class:
readonly YourDomain: YourDomainAPI;
// Add to constructor opts type:
yourDomainHost?: YourDomainHost;
// Add to constructor body:
this.YourDomain = new YourDomainAPI(opts?.yourDomainHost);
// packages/player/src/services/plugins/createPluginAPI.ts
import { yourDomainHost } from '../../services/yourDomainHost';
// Add to NuclearPluginAPI constructor call:
yourDomainHost,
Host implementation
Hosts bridge the SDK contract to whatever backs the domain. Most commonly a Zustand store:
// packages/player/src/services/yourDomainHost.ts
import type { YourDomainHost } from '@nuclearplayer/plugin-sdk';
import { useYourDomainStore } from '../stores/yourDomainStore';
export const createYourDomainHost = (): YourDomainHost => ({
yourMethod: (arg) => useYourDomainStore.getState().doThing(arg),
getState: () => useYourDomainStore.getState().value,
});
export const yourDomainHost = createYourDomainHost();
A host can also have access to the provider registry (metadata, streaming, dashboard), resolving which registered provider to call. Two patterns:
Single provider (user picks one in Sources):
const getProvider = (providerId?: string) =>
providersHost.get<YourProvider>(
providerId ?? providersHost.getActive('yourkind'), 'yourkind',
);
export const createYourDomainHost = (): YourDomainHost => ({
async fetch(query, providerId?) {
const provider = getProvider(providerId);
if (!provider) throw new Error('No provider available');
return provider.fetch(query);
},
});
Fan-out (aggregate across all providers - dashboard):
export const createYourDomainHost = (): YourDomainHost => ({
async fetchAll() {
const providers = providersHost.list('yourkind') as YourProvider[];
const results = await Promise.allSettled(providers.map(async (provider) => {
const items = await provider.fetch();
return { providerId: provider.id, providerName: provider.name, items };
}));
return results.filter(isFulfilled).map((r) => r.value);
},
});
Reference: packages/player/src/services/metadataHost.ts (single), packages/player/src/services/dashboardHost.ts (fan-out)
Error handling
- Store-backed hosts: let errors propagate naturally.
- Provider-backed, single provider (metadata, streaming): throw errors so the user sees failures.
- Provider-backed, fan-out (dashboard): catch per-provider so one failure doesn't block others. Log via
reportError, return partial results. MissingCapabilityError: always a plugin bug. Single-provider domains throw it; fan-out domains log it and skip the offending provider.