Author & publish a SignalK plugin
Hard-won notes for building a SignalK Node-server plugin and shipping
it to npm. Mirror a clean reference: openwatersio/signalk-tides
(TypeScript) is the best one to read for the @signalk/server-api calls.
1. Scaffold
Public repo, npm package, MIT, zero runtime deps where possible. Files: index.js (or
TS src/ built with tsc), package.json, LICENSE, README.md, .gitignore
(node_modules, *.tgz), tests (index.test.js via node:test, or test/ via vitest),
.github/workflows/{test.yml,publish.yml}.
package.json essentials — a missing files ships nothing because .gitignore excludes the build:
{
"name": "signalk-<name>",
"type": "module",
"files": ["index.js"],
"keywords": ["signalk-node-server-plugin", "signalk-category-utility"],
"license": "MIT"
}
For a scoped package add "publishConfig": { "access": "public" }. For TypeScript use
"files": ["dist"], point "main": "dist/index.js" (and "types": "dist/index.d.ts") at the
build output — without main the server resolves a nonexistent root index.js — and add
"prepare": "npm run build" so npm publish builds first. The
signalk-node-server-plugin keyword is what surfaces it in the SignalK app store.
2. Write new plugins as ESM
Prefer "type": "module" over CJS for anything new. The server has loaded ESM plugins since
v2.14.0 (June 2025): it require()s the plugin directory first — Node ≥ 20.19 / ≥ 22.12
loads ESM through require by default — and falls back to dynamic import() resolved via
esm-resolve (plain import() can't take a directory path), so ESM plugins load even on Nodes
where require(esm) isn't available. (Server 2.30.0 itself requires Node ≥ 22; v2.14.0
required ≥ 20.) The require path unwraps mod.default ?? mod; the import() fallback
returns module.default directly — anything without one loads as undefined there. So the
entry point must export default function (app) { ... } returning the plugin object. The practical reason
to switch: new majors of common dependencies ship ESM-only, and a CJS plugin can only reach
those through awkward dynamic import() — an ESM plugin just imports them. For TypeScript,
"module": "nodenext" with a default export compiles to the same shape — remember nodenext
makes relative imports require explicit .js extensions (./helpers.js, even in .ts files).
3. The @signalk/server-api patterns that actually work
- Serve plugin data via
app.registerResourceProvider({ type, methods: { listResources, getResource, setResource, deleteResource } })— it's served at/signalk/v2/api/resources/<type>and is anonymously readable under the server'sallow_readonly. Do NOT serve data withregisterWithRouter—/plugins/<id>/*routes are admin-gated, so every consumer would need an admin token. This is the single biggest gotcha. - Publish a value:
app.handleMessage(plugin.id, { updates: [{ values: [{ path: '<path>' as Path, value }] }] }). - Read the vessel position:
app.getSelfPath('navigation.position.value') as Position | undefined(note the.valuesuffix). - Do periodic work in
setInterval, and wrap each cycle — and each independent step inside it — intry/catch→app.error(...), so one failing fetch can't blank everything else or kill the loop. - Avoid an
expressruntime dependency: register routes on theIRouterthe server hands you viaregisterWithRouter, or use the resource API; keep@types/expressdev-only viaimport type(erased at build).
Define the config schema once, in TypeBox
@signalk/server-api ships @sinclair/typebox and exports SignalK domain schemas at
@signalk/server-api/typebox (course, notifications, resources, weather, …). TypeBox types
are JSON Schema objects at runtime, so one definition is both the admin-UI config form and
the TypeScript type:
import { Type, type Static } from '@sinclair/typebox'
const ConfigSchema = Type.Object({
stationName: Type.String({ title: 'Station name', default: 'my-station' }),
intervalSeconds: Type.Number({ title: 'Poll interval (s)', default: 60, minimum: 5 }),
alerts: Type.Optional(Type.Boolean({ title: 'Enable alerts' }))
})
type Config = Static<typeof ConfigSchema> // the type of start(config)
// in the plugin object: schema: () => ConfigSchema
Declare @sinclair/typebox in the plugin's own dependencies — don't rely on the server's
copy being hoisted into reach. The admin UI renders title/description/default as-is. Two
gotchas: every property not wrapped in Type.Optional(...) lands in the schema's required
list, so wrap truly optional fields; and the package is the scoped @sinclair/typebox
(0.34.x — what server-api uses). The unscoped typebox on npm is the separate 1.x line with
a different API — don't mix them.
4. Plugin webapp state: a store, not useState
If the plugin ships a webapp or an embedded config panel, keep view state that must survive
navigation — active tab, filter, sort order, selection — in a small store rather than component
useState. Components unmount on route round-trips (open a detail view, navigate back) and
useState silently resets to defaults, which users read as "my selection got lost"; store
state survives the remount. Use Zustand — it's what the server's own admin UI uses (v5),
so you add no new concept to the stack — and this exact bug class has shipped in the admin UI
itself, so treat it as the default trap, not an edge case.
5. Publish to npm
Ship via OIDC trusted publishing so each GitHub release auto-publishes with no token/OTP.
The full flow — the release-triggered publish.yml, the new-package first-publish
chicken-and-egg (CLI+OTP once, then configure the trusted publisher), and the
registry-propagation 404 gotcha — is in the npm-oidc-publish skill in this marketplace.
SignalK-specific bits: the signalk-node-server-plugin keyword is what surfaces the package
in the app store, and ship index.js/dist via "files".
6. Install on a SignalK server
Install from the admin UI Appstore (search your plugin), or npm install signalk-<name>
in the server's data dir (~/.signalk), then restart. Config persists under
~/.signalk/plugin-config-data/. If the server runs in Docker and you develop locally,
never bind-mount a plugin inside node_modules — the app store reifies that tree with npm
and can't rename a mount point (EBUSY), which breaks every plugin install/update. Mount
outside node_modules and link it with a file: dep, or just npm install it as a tracked
dependency (anything extraneous gets pruned on the next reify).
7. Install scripts never run — design for it
- The app store installs plugins with
npm --save --ignore-scripts install(read from the released server's install path). Your plugin'sinstall/postinstall— and those of every dependency — are skipped on every app-store install. ("prepare": "npm run build"from the scaffold is unaffected: it runs on your machine at publish, not on the user's at install.) - npm 12 extends the same to manual installs:
latestsince July 2026, it skips dependency lifecycle scripts by default with only anpm warn install-scriptshint — and a plugin cannot whitelist itself, becauseallowScriptsis honored only in the install root's package.json, never in a dependency's own. - The failure mode is silent. The install "succeeds"; the missing build artifact surfaces
only at runtime ("Failed to load native canSocket module" — the
node-gyp rebuild-in-postinstall class of breakage that hit every server image when npm moved 11→12). - So: no load-bearing install scripts anywhere in your dependency tree. Prefer pure JS;
where native is unavoidable, use N-API prebuilds resolved at
requiretime (the serialport pattern — it kept working throughout). CI check that costs nothing: install your plugin withnpm install --ignore-scriptsand run the tests against that tree. - Need more than a script-free npm package can deliver — a native toolchain, real compute,
an external service? Don't fight the constraint: ship that part as a container your
plugin manages through the signalk-container
manager (the
signalk-container-helperlibrary packages the container lifecycle), and keep the npm plugin itself thin.
8. Where to store what
Four distinct places, and picking the wrong one is a delayed-loss bug:
- Never inside the plugin's install directory (
__dirname, anywhere undernode_modules). The app store reinstalls/reifies that tree on every update — files written there silently vanish. This is the classic "worked for months, gone after an update" report. - Plugin configuration → let the server own it. What the user sets in the config form
arrives as
start(config); the server persists it at<configdir>/plugin-config-data/<pluginid>.json. Update it programmatically viaapp.savePluginOptions(...)— never write that file yourself. - Plugin runtime data →
app.getDataDirPath(), which is the per-plugin directory<configdir>/plugin-config-data/<pluginid>/. It survives plugin updates and travels with the server's config dir (and its backups). This is where caches, downloaded files, and databases belong — the bundled resources-provider keeps its waypoints/routes there. - Webapp and per-user settings → the applicationData REST API:
/signalk/v1/applicationData/{user|global}/<appid>/<version>, persisted by the server under<configdir>/applicationData/{users/<name>|global}/<appid>/<version>.json.userscope is per logged-in user; the<version>segment gives settings-per-app-version for free. Gotcha: with security disabled the whole interface answers 405 "security is not enabled" — a webapp using it must handle that response on open-security servers. - Boat data isn't a file at all — publish it into the data model (
handleMessage) or behind a provider registration, so every consumer sees it through the normal APIs.
Testing
Keep pure helpers (parsing, mapping, math) separate and test them directly; inject/mock the
I/O boundary (HTTP fetches, the SignalK app.* calls), or exercise it against a throwaway
local http server in the test. node:test for JS, vitest for TS.
ESM loading (via the require path on Node 24) and the TypeBox config form — render, save,
start(config), delta emission — verified end-to-end against signalk-server 2.30.0 /
@signalk/server-api 2.30.0, August 2026. The ESM loader mechanics are from the server's
importOrRequire in src/modules.ts.