@tx5dr/plugin-api
v2.5.0
Published
Public plugin API for TX-5DR digital radio engine
Maintainers
Readme
@tx5dr/plugin-api
Public plugin API for the TX-5DR digital radio engine.
Plugin authors should import from this package instead of reaching into internal monorepo packages. It provides TypeScript types for plugin definitions, runtime helpers, logbook sync providers, and the iframe Bridge SDK.
Installation
npm install --save-dev @tx5dr/plugin-apiQuick Start
TypeScript
import { definePlugin } from '@tx5dr/plugin-api';
const plugin = definePlugin({
apiVersion: 2,
name: 'my-plugin',
version: '1.0.0',
type: 'utility',
permissions: [],
hooks: {
onDecode(messages, ctx) {
for (const msg of messages) {
ctx.log.debug('Decoded', { raw: msg.rawMessage });
}
},
},
});
export default plugin;JavaScript (with JSDoc types)
import { definePlugin } from '@tx5dr/plugin-api';
export default definePlugin({
apiVersion: 2,
name: 'my-plugin',
version: '1.0.0',
type: 'utility',
permissions: [],
hooks: {
onDecode(messages, ctx) {
for (const msg of messages) {
ctx.log.debug('Decoded', { raw: msg.rawMessage });
}
},
},
});Exports
| Subpath | Description |
|---------|-------------|
| @tx5dr/plugin-api | definePlugin(), capability-derived contexts, hooks, structured command ports, and radio/message types |
| @tx5dr/plugin-api/testing | Mock factories for unit testing: createMockContext(), createMockSlotInfo(), createMockParsedMessage(), createMockEventBus() |
| @tx5dr/plugin-api/contest | Composable FT8/FT4 exchange, completion, dupe, scoring, submission, session and plugin assembly modules |
| @tx5dr/plugin-api/toolkit | Advanced contest and parallel-QSO building blocks |
| @tx5dr/plugin-api/bridge | Ambient type declarations for the iframe Bridge SDK (window.tx5dr) |
Release boundary: 2.0.1 is the containment release for the existing
experimental/advanced ./toolkit surface. The stable ./contest entry point,
root re-exports and ft8-contest scaffold start at 2.5.0; the publish workflow
rejects an older version when that stable export is present.
Plugin compatibility uses the bundled @tx5dr/plugin-api SemVer, not the
TX-5DR product/nightly version. Marketplace plugins declare
minPluginApiVersion; apiVersion: 2 remains the separate runtime ABI marker.
FT8 Contest Composition
Contest rules are public, replaceable modules. composeFT8ContestPlugin() is a
convenience entry point, not a required base class: a plugin may also import the
same modules and connect them with definePlugin() itself.
import {
cabrilloSubmission,
CONTEST_LOGBOOK_PERMISSIONS,
composeFT8ContestPlugin,
defineFT8Contest,
distancePoints,
fixedWeekendEdition,
gridExchange,
requireExchangeAndFinalAck,
standardFT8ContestLogbook,
} from '@tx5dr/plugin-api/contest';
import { createContestRuntime } from './runtime.js';
const contest = defineFT8Contest({
id: 'example-ft8',
rulesetVersion: '2026.1',
edition: fixedWeekendEdition({
id: '2026',
startAt: '2026-08-29T00:00:00Z',
endAt: '2026-08-30T00:00:00Z',
}),
bands: ['80M', '40M', '20M', '15M', '10M'],
exchange: gridExchange(),
completion: requireExchangeAndFinalAck(),
scoring: distancePoints({ stepKm: 3000 }),
presentation: {
summary: 'FT8/FT4 grid exchange contest.',
scoring: 'One point plus one point per 3000 km step; grid fields provide multipliers.',
exchange: 'Four-character Maidenhead grid',
},
submission: cabrilloSubmission({
headers: () => [['CONTEST', 'EXAMPLE-FT8']],
qsoLine: (qso) => `QSO: ${qso.callsign}`,
}),
});
export default composeFT8ContestPlugin({
name: 'example-ft8',
version: '1.0.0',
minPluginApiVersion: '2.5.0',
permissions: CONTEST_LOGBOOK_PERMISSIONS,
contest,
runtime: createContestRuntime,
logbook: standardFT8ContestLogbook({ contest }),
});For fixed-point sprints, prefix/DXCC/grid multipliers, or contests that need
zero-point QSOs to remain multiplier-eligible, combine fixedPoints(),
scoreBy() and multiplierKeysFrom() with your own contest metadata accessor.
distancePoints() is the common shortcut for distance-based FT8/FT4 scoring,
not the only way to compose a contest.
The definition defaults to FT8, one QSO, one signal, human initiation and
callsign-per-band duplicate checking. Completion remains mandatory because a
plugin must choose its RF fail-closed evidence policy explicitly. The assembler
maps human initiation and QSO/simultaneous-signal limits into Host-enforced
strategy capabilities. cycleRelation remains a runtime-adapter responsibility
because it depends on the contest protocol's lane semantics. Optional
defaultContestSession() and defaultContestWorkbench() hide storage keys and
page routing while keeping their module interfaces replaceable. For a complete
operator-facing contest logbook, use standardFT8ContestLogbook({ contest });
it supplies the standard page, panel, bridge protocol, ADIF import/export and
official submission format. The ft8-contest scaffold copies the canonical
page assets automatically. The definition's presentation metadata is shown
as collapsible rule and scoring details with a link to the official rules; when
metadata is absent, the corresponding detail is omitted.
The standard logbook also derives the FrameTable's new-callsign and contest
multiplier presentation from the independent contest session, and the composer
adds it to the strategy snapshot automatically.
Single-stream contests may add strategyFeatures.targetQueue explicitly.
Definitions with more than one concurrent QSO automatically require the Host's
parallel queue contract; the assembler validates the returned runtime as soon
as it is created. Contest identities, editions and rule-module entry points are
snapshotted when defineFT8Contest() returns.
Use projectFT8ContestQsos(), scoreFT8ContestQsos() and
formatFT8ContestSubmission() as the common eligibility path. They apply the
edition window, supported modes/bands, review/excluded state and dupe policy
before score or export. Allocate serials with nextContestSerial() inside a
session transact() planner so a revision retry recalculates the serial from the
latest durable QSO snapshot.
For a contest application, declare the shared session capabilities and let the facade own Host session keys, event topics and revision-conflict retries:
import {
CONTEST_SESSION_PERMISSIONS,
createContestQsoEnvelopeAdapter,
defaultContestSession,
} from '@tx5dr/plugin-api/contest';
const session = defaultContestSession({
create: () => ({ schemaVersion: 1, revision: 0, settings: {} }),
});
const contestEnvelope = createContestQsoEnvelopeAdapter(contest);
const contestEntry = contestEnvelope.create({
sent: { grid: 'PL04' },
received: { grid: 'FN31' },
annotations: { status: 'review' },
});
// Inside a current Host callback:
await session.access(ctx).transact(
(snapshot) => planImportMutations(parsedImport, snapshot),
{ reason: 'import' },
);The facade opens the Host plugin-session on each current invocation, exposes
health/query/snapshot, retries a snapshot-planned QSO batch up to three times,
and publishes UI/event-bus notifications after commit. The plugin still parses
the file and owns its exchange, review fields and mutations. It never receives
the raw Host session handle. Use CONTEST_SESSION_PERMISSIONS as the plugin's
permissions when composing this default module.
Session identity includes plugin owner, station callsign, contestId,
editionId and rulesetVersion. The default facade is durable-only because its
cleanup context cannot destroy a runtime Host session; runtime practice/session
lifecycles must use strategy logbookSessionEffects. The envelope adapter
freezes the contest identity and uses the definition's typed exchange codec for
create/validation.
defaultContestWorkbench() exports a recommended ContestWorkbenchCommand
union for get-state, settings, QSO review, import and export action names plus
a narrow ContestWorkbenchViewModel. It is not a closed action DSL: plugins may
extend ContestWorkbenchRequest with custom actions. The plugin supplies typed
decoding, handlers and contest-specific generic fields; the SDK does not impose
a UI framework.
Capability Model
Privileged Host APIs use an allowlist model. A plugin must declare every
capability in permissions; the Host then projects only those properties into
that plugin's context. Undeclared properties are absent from both the inferred
TypeScript type and the runtime object. Use definePlugin() without manually
widening callbacks to PluginContext, otherwise TypeScript cannot preserve the
literal permission tuple.
Capabilities grant structured Host ports, not physical device ownership. The capability-derived context does not directly expose raw PTT, audio playback, the mixer, encoder, physical frame lease, arbitrary radio capability writes, or global emergency stop. Strategy runtimes receive a narrower speculative context and return declarative decisions; they never receive command ports.
For a strategy plugin, type: 'strategy' plus apiVersion: 2 is the explicit
declaration that it may produce RF decisions when selected by an operator. It
does not need operator:transmit-control. That permission is reserved for
utility plugins that need the imperative, Host-coordinated
ctx.operatorCommands port.
API v2 is required for strategy plugins and any plugin requesting a mutation capability. The Host validates and freezes the loaded definition so permissions cannot be expanded after load.
This is a Host API contract, not a sandbox for hostile Node.js code. Third-party plugins currently execute in the server process; process isolation is a separate security boundary.
Data Ownership and Callback Lifetime
Configuration, hook arguments, query results and messages cross the Host boundary by value. Plugins may modify their local copies, but changes are not persisted until they call an explicit API such as ctx.updateConfig(), store.set() or a command port. UI/config/KV channels accept JSON-compatible values; hooks, strategy results and EventBus payloads accept structured-clone-compatible values. Functions, cycles, Host handles and other unsupported values are rejected with PLUGIN_DATA_NOT_SERIALIZABLE.
Host capabilities such as ctx.ui, ctx.logbook, radio command ports, network sockets and native Response objects are live handles. Use them only before the current Host callback settles. A handle retained past timeout, reload or unload rejects with PLUGIN_INVOCATION_EXPIRED; use Host timers and callbacks for later work instead of detached continuations.
Operator Transmit Control
Utility plugins that need to submit operator commands must declare
operator:transmit-control and submit one of the high-level commands accepted
by ctx.operatorCommands. The permission grants API access; it does not by
itself classify the plugin as an automatic caller.
Automatic calling plugins implement isAutoCallEnabled(). This both gates the
command port and opts the plugin into the operator auto-call indicator and pause
controls:
const plugin = definePlugin({
apiVersion: 2,
name: 'scheduled-caller',
version: '1.0.0',
type: 'utility',
permissions: ['operator:transmit-control'],
isAutoCallEnabled: (ctx) => ctx.config.enabled === true,
hooks: {
async onTimer(_timerId, ctx) {
await ctx.operatorCommands.submit({
type: 'request-call',
callsign: 'W1AW',
});
},
},
});Integrations such as remote-control protocol bridges that may submit occasional
commands but do not autonomously originate calls implement
isTransmitControlEnabled() instead. They receive the same guarded command
port but are not shown or paused as auto-call plugins.
The Host allocates an operator command epoch and routes the request through the operator/frame coordinators. Plugins cannot directly key or unkey the radio.
Radio Permissions
ctx.radio always exposes a small read-only operating snapshot. Additional
radio capabilities require explicit declarations:
permissions: ['radio:read', 'radio:control', 'radio:tuner-control', 'radio:power']radio:readexposesctx.radioCapabilitiesand the read-onlyctx.radioPowerview.radio:controlexposes Host-arbitratedset-frequencyandswitch-bandcommands.switch-bandcan includeautoTune: truewhenradio:tuner-controlis also declared; the Host keeps the complete operation inside one physical-idle fence.radio:tuner-controlexposes onlyset-enabledandstart-manual-tunethroughctx.radioTunerCommands.- Radio writes reject while Digital, Voice, CW, Tune or manual PTT owns the physical transmitter; they never interrupt that transmission.
radio:powerexposesctx.radioPowerCommands.submit({ type: 'set-power', state }).ctx.radio.moderemains read-only and uses ADIFMODE/SUBMODEsemantics.
These APIs are not exposed directly to iframe pages; custom UI should call a server-side page handler.
Logbook Permissions
logbook:readexposes only query and worked-status methods onctx.logbook.logbook:writeexposes durableaddQSO()/updateQSO()mutations in addition to reads.logbook:syncexposesctx.logbookSyncfor registering a Host-managed sync provider.
Write completion means the Host logbook durability contract has completed. Logbook APIs never grant PTT or frame lifecycle control.
Host Settings Permissions
Server-side plugins can use ctx.settings to read or update a safe whitelist of host settings when the manifest declares the matching permission. Each settings namespace uses one read/write permission:
| Namespace | Permission | Methods |
|-----------|------------|---------|
| ctx.settings.ft8 | settings:ft8 | get(), update(patch) |
| ctx.settings.decodeWindows | settings:decode-windows | get(), update(settings) |
| ctx.settings.realtime | settings:realtime | get(), update(settings) |
| ctx.settings.frequencyPresets | settings:frequency-presets | get(), update(presets), reset() |
| ctx.settings.station | settings:station | get(), update(patch) |
| ctx.settings.pskReporter | settings:psk-reporter | get(), update(patch) |
| ctx.settings.ntp | settings:ntp | get(), update({ servers }) |
import { definePlugin } from '@tx5dr/plugin-api';
const plugin = definePlugin({
apiVersion: 2,
name: 'station-policy',
version: '1.0.0',
type: 'utility',
permissions: ['settings:ft8', 'settings:station'],
hooks: {
async onLoad(ctx) {
await ctx.settings.ft8.update({ maxSameTransmissionCount: 0 });
await ctx.settings.station.update({ callsign: 'W1AW' });
},
},
});
export default plugin;The whitelist intentionally excludes authentication tokens, operator CRUD, hardware radio connection settings, audio devices, rigctld, OpenWebRX, profiles, and server host/port settings. These APIs are not exposed directly to iframe pages; custom UI should call a server-side page handler with window.tx5dr.invoke().
Plugin Event Bus
Server-side plugins can exchange in-process messages through ctx.eventBus, a topic-based pub/sub bus scoped to the host process. Payloads use structured-clone semantics and each subscriber receives an independent value, enabling loose coupling without shared mutable state. Functions, promises, weak collections, and host capability objects cannot be published.
Permission
Declare plugin:event-bus in the manifest to enable the bus:
permissions: ['plugin:event-bus']ctx.eventBus is optional and should be feature-detected before use.
API Summary
| Method | Description |
|--------|-------------|
| publish(topic, payload?) | Fire-and-forget message to all current subscribers of the exact topic. |
| subscribe(topic, handler) | Registers a handler; returns an unsubscribe function. |
Every message received by a subscriber is a PluginEventBusMessage:
interface PluginEventBusMessage {
topic: string; // The topic this message was published to
payload: unknown; // Independent structured-clone value
timestamp: number; // Epoch ms when the host dispatched the message
publisher: {
pluginName: string; // Publishing plugin's name
instanceScope: 'operator' | 'global';
operatorId?: string; // Present when the publisher is operator-scoped
};
}Topic Naming Convention
Use dot-separated, plugin-prefixed names to avoid collisions between plugins:
<plugin-name>.<domain>.<event>Examples:
psk-reporter.spot.sent— a spot was uploaded to PSK Reportercallsign-filter.match.found— a callsign matched a filter rulelogbook-sync.upload.complete— a logbook sync finished
Avoid generic names like update or message — they will collide.
Basic Usage
import { definePlugin } from '@tx5dr/plugin-api';
// Publisher plugin
const publisher = definePlugin({
apiVersion: 2,
name: 'spot-monitor',
version: '1.0.0',
type: 'utility',
permissions: ['plugin:event-bus'],
hooks: {
onDecode(messages, ctx) {
for (const msg of messages) {
ctx.eventBus.publish('spot-monitor.new-spot', {
callsign: msg.callsign,
frequency: msg.frequencyHz,
});
}
},
},
});
// Subscriber plugin
const subscriber = definePlugin({
apiVersion: 2,
name: 'spot-logger',
version: '1.0.0',
type: 'utility',
permissions: ['plugin:event-bus'],
hooks: {
onLoad(ctx) {
ctx.eventBus.subscribe('spot-monitor.new-spot', (message) => {
ctx.log.info('received spot', {
from: message.publisher.pluginName,
callsign: (message.payload as any).callsign,
});
});
},
},
});Cross-Operator Communication
Operator-scoped plugins can communicate across operators on the same host. The publisher metadata lets subscribers identify which operator sent the message:
ctx.eventBus.subscribe('qso-monitor.qso-complete', (message) => {
const { callsign, band } = message.payload as any;
ctx.log.info('QSO completed by another operator', {
operator: message.publisher.operatorId,
callsign,
band,
});
});Lifecycle and Error Handling
- Auto-cleanup: the host removes all subscriptions when a plugin instance unloads. No manual cleanup required.
- Manual unsubscribe: call the function returned by
subscribe()to cancel a single subscription early. - Error isolation: subscriber exceptions (sync or async) are captured and logged by the host. They never propagate back to the publisher.
- Delivery order: subscribers receive messages in registration order. Async handlers are awaited, but the publisher does not wait for completion.
Testing
Use createMockEventBus() from @tx5dr/plugin-api/testing to test plugin event bus logic in isolation:
import { describe, it, expect, vi } from 'vitest';
import { createMockEventBus } from '@tx5dr/plugin-api/testing';
it('publishes spot data', () => {
const bus = createMockEventBus({ owner: { pluginName: 'spot-monitor' } });
const handler = vi.fn();
bus.subscribe('spot-monitor.new-spot', handler);
bus.publish('spot-monitor.new-spot', { callsign: 'W1AW', frequency: 14074000 });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.objectContaining({
topic: 'spot-monitor.new-spot',
payload: { callsign: 'W1AW', frequency: 14074000 },
publisher: expect.objectContaining({ pluginName: 'spot-monitor' }),
}));
});
it('tracks published messages', () => {
const bus = createMockEventBus();
bus.publish('topic-a', { value: 1 });
bus.publish('topic-b', { value: 2 });
expect(bus._published).toHaveLength(2);
expect(bus._published[0].topic).toBe('topic-a');
});
it('unsubscribe prevents further delivery', () => {
const bus = createMockEventBus();
const handler = vi.fn();
const unsub = bus.subscribe('topic', handler);
bus.publish('topic', 'first');
unsub();
bus.publish('topic', 'second');
expect(handler).toHaveBeenCalledTimes(1);
});The mock records all published messages in _published and exposes the internal _subscriptions map for advanced inspection.
Bridge SDK Types
Plugin iframe pages communicate with the host via the Bridge SDK (window.tx5dr), which is automatically injected by the host. To get IDE autocomplete for the Bridge SDK, add the type reference to your project:
tsconfig.json / jsconfig.json:
{
"compilerOptions": {
"types": ["@tx5dr/plugin-api/bridge"]
}
}Or per-file:
/// <reference types="@tx5dr/plugin-api/bridge" />
tx5dr.invoke('getState').then(function(state) {
// Full autocomplete for tx5dr methods
});
// Opens the system default browser in Electron, or a new tab in Web.
tx5dr.openExternal('https://example.com/rules');CSS Design Tokens
The host injects CSS custom properties (--tx5dr-*) into every iframe page. A reference copy is included in this package at tokens.css — copy it into your project for CSS autocomplete in your IDE:
cp node_modules/@tx5dr/plugin-api/tokens.css ./ui/Then use the tokens in your plugin CSS:
.container {
background: var(--tx5dr-bg-content);
color: var(--tx5dr-text);
border-radius: var(--tx5dr-radius-md);
padding: var(--tx5dr-spacing-md);
font-family: var(--tx5dr-font);
}The host deliberately does not inject component classes. Plugins own their markup and CSS, while the token layer exposes enough primitives to reproduce the host's control variants consistently. For example, a plugin can assemble its own flat primary button:
.button {
min-height: var(--tx5dr-control-height-sm);
padding-inline: var(--tx5dr-control-padding-x-sm);
border: 1px solid transparent;
border-radius: var(--tx5dr-radius-sm);
font-size: var(--tx5dr-control-font-size-sm);
font-weight: var(--tx5dr-control-font-weight);
transition: background-color var(--tx5dr-control-transition);
}
.button--flat-primary {
color: var(--tx5dr-primary);
background: var(--tx5dr-primary-soft);
}
.button--flat-primary:hover:not(:disabled) {
background: var(--tx5dr-primary-soft-hover);
}
.button:disabled {
opacity: var(--tx5dr-control-disabled-opacity);
}The same primitives support plugin-owned solid, flat, bordered, and
light variants. Semantic soft/foreground tokens are available for primary,
success, warning, and danger controls; compact status, table, alert, shadow,
focus, spacing, and typography primitives use the corresponding
--tx5dr-control-*, --tx5dr-chip-*, --tx5dr-table-*, and base tokens.
Testing
慢速历史扫描、缓存预热或迁移应通过 Host 的启动后任务执行:
context.lifecycle.scheduleAfterStartup('history-prewarm', async (signal) => {
if (signal.aborted) return;
await rebuildHistory(signal);
});Host 会在 Server ready 后调度任务,并在插件实例卸载或重载时自动取消。
import { describe, it, expect } from 'vitest';
import {
createMockContext,
createMockSlotInfo,
createMockParsedMessage,
} from '@tx5dr/plugin-api/testing';
import plugin from './index.js';
describe('my-plugin', () => {
it('processes decoded messages', () => {
const ctx = createMockContext();
const messages = [createMockParsedMessage({ rawMessage: 'CQ W1AW FN31' })];
plugin.hooks!.onDecode!(messages, ctx);
expect(ctx.log._calls.some(c => c.level === 'debug')).toBe(true);
});
});Documentation
For the full plugin system guide, see docs/plugin-system.md.
License
MIT
