@liquio/plugin-sdk
v1.0.0
Published
Provider/plugin interfaces and runtime loader shared by Liquio backend components
Readme
@liquio/plugin-sdk
Provider/plugin interfaces and runtime loader shared by Liquio backend components (task, event, external-reader). This package is what you build against if you're writing a Liquio plugin, and it's what those components use internally to discover, validate, and load plugins at startup.
A plugin is an ordinary npm package that:
- exports a class extending one of the three provider base classes below,
- declares a
liquioPluginblock in itspackage.jsondescribing what kind of plugin it is, - gets listed in a host component's
plugins.jsonconfig, - gets installed into a shared volume at container startup by
@liquio/plugin-installer, - and is
require()'d and instantiated by the host'sPluginLoaderat runtime.
Plugin kinds
There are three provider base classes, one per plugin kind. Pick the one matching what you're integrating:
| Kind (liquioPlugin.kind) | Base class | Used by |
| --- | --- | --- |
| event-external-service | EventExternalServiceProvider | event |
| task-payment-provider | TaskPaymentProvider | task |
| external-reader-provider | ExternalReaderProvider | external-reader |
All three extend BasePlugin, which gives you:
export interface PluginContext {
log: PluginLogger; // structured logging (see below)
pluginConfig: Record<string, unknown>; // this plugin's `options` from plugins.json
}
export abstract class BasePlugin<TOptions = Record<string, unknown>> {
protected readonly options: TOptions; // same object as context.pluginConfig, typed
protected readonly context: PluginContext;
constructor(context: PluginContext, options: TOptions) { ... }
async onInit(): Promise<void> {} // called once, right after construction
async onDestroy(): Promise<void> {} // reserved for future use - not currently invoked by the loader
}this.options and this.context.pluginConfig carry the same data — this.options is just the typed version, generic over whatever options shape your plugin declares.
EventExternalServiceProvider
export interface ExternalServiceSendResult {
request: unknown;
response: unknown;
isDone: boolean;
}
export interface ExternalServiceSendContext {
filestorage?: unknown;
documentModel?: unknown;
taskModel?: unknown;
workflowId?: string;
}
export abstract class EventExternalServiceProvider<TOptions = Record<string, unknown>> extends BasePlugin<TOptions> {
abstract send(
data: unknown,
isTest?: boolean,
ctx?: ExternalServiceSendContext,
): Promise<ExternalServiceSendResult>;
}Implement send(). See @liquio/event-xroad-plugin for a real example.
TaskPaymentProvider
A full payment gateway integration. Implement all of:
calculatePayment(data: TaskPaymentData): Promise<unknown>
handleStatus(data, providerOptions, status: string, queryParamsObject, headersObject, checkPrevTransaction?: boolean): Promise<unknown>
confirmBySmsCode(providerOptions, calculatedData, smsCode: string): Promise<unknown>
cancelOrder(providerOptions, orderId: string, transactionId: string, sessionId: string): Promise<unknown>
unHoldOrder(data: unknown): Promise<unknown>
checkStatus(providerOptions, sessionId: string, invoiceId: string): Promise<unknown>
getPaymentReceiptInfo(args: { paymentSystemParams: unknown; orderId: string }): Promise<unknown>
getPaymentReceiptFiles(args: { paymentSystemParams; orderId; receiptFormat; paymentControlSchema }): Promise<Array<{ fileBuffer: ArrayBuffer; contentType: string }>>
getWithdrawalFundsStatus(args: { paymentSystemParams; orderId }): Promise<unknown>
sendCheckRequest(providerOptions: unknown): Promise<unknown>TaskPaymentData supports both a single resolved payment (amount, orderId,
and related fields) and the resolved recipients list form used by task payment
controls. Providers that create one checkout from a recipient list are
responsible for applying their gateway's aggregation rules.
ExternalReaderProvider
A registry pattern rather than a fixed method set — register whichever read methods your plugin exposes:
export type ProviderMethod = (args: ProviderMethodArgs) => Promise<unknown>;
export interface ProviderMethodArgs {
userFilter?: Record<string, unknown>;
nonUserFilter?: unknown;
extraParams?: Record<string, unknown>;
}
export abstract class ExternalReaderProvider<TOptions = Record<string, unknown>> extends BasePlugin<TOptions> {
protected registerMethod(name: string, method: ProviderMethod): void;
getMethod(name: string): ProviderMethod | undefined;
listMethods(): string[];
}Call this.registerMethod("methodName", async (args) => {...}) for each method you want to expose — typically from your constructor (after super(...)) or from onInit().
Logging
context.log (also reachable as this.context.log in a subclass) implements the minimal PluginLogger contract:
export interface PluginLogger {
save(type: string, data?: unknown, level?: string): unknown;
}Call it as this.context.log.save("my-plugin|something-happened", { some: "data" }, "error"). type is a free-form string — the convention used by existing plugins is <namespace>|<event> (e.g. send-to-trembita|request-options, send-to-trembita|parsed-response|error). level defaults to info-level when omitted; use "warning"/"error" for problems. The host component logs these as structured JSON alongside its own log stream — there's no separate logger to set up.
Writing a plugin
Set up the package. A plugin is a normal npm package with a build step that produces
dist/. Only publishdist("files": ["dist"]inpackage.json).Declare the manifest. Add a
liquioPluginblock topackage.json:{ "name": "@yourscope/your-plugin", "main": "dist/index.js", "files": ["dist"], "liquioPlugin": { "kind": "event-external-service", "sdkVersion": "^0.1.0" }, "dependencies": { "@liquio/plugin-sdk": "^0.1.0" } }kindmust be one ofevent-external-service,task-payment-provider,external-reader-provider.sdkVersionis checked against the host's installed@liquio/plugin-sdkversion at load time — but only the major version is compared (a leading^/~is stripped before comparing). It's not a full semver range check, so pin loosely and don't rely on it for minor/patch compatibility guarantees.mainis read from the top-levelpackage.jsonfield (not from insideliquioPlugin) and defaults todist/index.jsif omitted.
Export your class as the module's default export. The loader does
entry.default ?? entryon the required module, so:// src/index.ts import { YourProvider } from "./your_provider"; export { YourProvider }; export default YourProvider;Implement the base class. Minimal example (
event-external-service):import { EventExternalServiceProvider, ExternalServiceSendResult, ExternalServiceSendContext } from "@liquio/plugin-sdk"; interface YourPluginOptions { apiUrl: string; timeout?: number; } export class YourProvider extends EventExternalServiceProvider<YourPluginOptions> { async send(data: unknown, isTest?: boolean, ctx?: ExternalServiceSendContext): Promise<ExternalServiceSendResult> { const { apiUrl } = this.options; this.context.log.save("your-plugin|sending", { apiUrl, isTest }); // ... call the external service ... return { request: data, response: {}, isDone: true }; } }this.optionsis typed asYourPluginOptions— that's whatever shape you expect the host'splugins.jsonoptionsfield to have for this plugin instance.Build and publish.
tsc(or your bundler of choice) todist/, then publish to whatever npm registry the host'splugin-installeris configured to use.
For a complete real-world reference, see @liquio/event-xroad-plugin — an event-external-service plugin with no custom constructor, one send() implementation, and structured logging throughout.
How plugins get loaded (host side)
You generally don't need to touch this as a plugin author, but it's useful to know what's happening:
The host component's
plugins.jsonlists enabled plugins.registryis optional (defaults to the installer'sNPM_REGISTRYenv var):{ "registry": "https://registry.npmjs.org", "plugins": [ { "package": "@yourscope/your-plugin", "version": "1.0.0", "isEnabled": true, "name": "your-plugin-instance", "options": { "apiUrl": "https://example.com" } } ] }At container startup, an init container running
@liquio/plugin-installernpm installs every enabledpackage@versioninto a shared volume (with--ignore-scriptsby default — setallowInstallScripts: trueinplugins.jsonif your plugin genuinely needs its install scripts to run). The top-levelregistryfield is optional and overrides the installer's default registry (NPM_REGISTRYenv var, itself defaulting tohttps://registry.npmjs.org) — set it if your plugin is published to a private/internal registry.The host component's own process constructs a
PluginLoaderand calls.load(pluginsConfig). For each enabled entry, it:- reads
<pluginsDir>/node_modules/<package>/package.jsonand validates theliquioPluginmanifest, - checks
sdkVersionmajor-version compatibility, require()s the module atmain(defaultdist/index.js),- constructs your class as
new YourProvider({ log, pluginConfig: entry.options ?? {} }, entry.options ?? {}), - awaits
onInit(), - registers the instance under
entry.namein aPluginRegistry.
A plugin that fails to load (bad manifest, version mismatch, throwing constructor/
onInit) is logged (plugin-load-error) and skipped — it does not stop other plugins from loading. Aplugin-load-summaryis logged afterward with counts of configured vs. loaded plugins.- reads
The host looks up your instance later via
pluginRegistry.get(name)(or, forexternal-reader-provider, callsgetMethod(name)on it) wherever it needs to invoke plugin behavior.
