npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

signalk-container-helper

v0.11.0

Published

Helper library for Signal K plugins that manage containers via the signalk-container plugin

Readme

signalk-container-helper

Helper library for Signal K plugin developers whose plugins run containers through the signalk-container plugin.

Every containerized Signal K plugin ends up hand-writing the same integration code: polling for the container manager, validating tags, calling ensureRunning, waiting for the app inside the container to answer HTTP, registering for update detection, mounting /api/update/check + apply routes, and stopping cleanly. This library packages those patterns — extracted from signalk-backup, mayara-server-signalk-plugin, signalk-doctor, and signalk-updater — into a small, typed, zero-dependency API.

It also ships the shared React config-panel building blocks those plugins hand-copied (container status card, image-version dropdown, update controls) as a separate browser-side entrypoint, signalk-container-helper/ui.

See SPEC.md for the full design rationale.

Install

npm install signalk-container-helper

Declare the signalk-container relationship in your plugin's package.json — do not add signalk-container to dependencies or peerDependencies (its prerelease versioning breaks npm semver ranges):

{
  "signalk": {
    "requires": ["signalk-container"]
  }
}

Requires Node ≥ 22. This library is published as an ES module (import, not require); consumers must be ESM too.

At runtime it works with signalk-container ≥ 1.6.0 — newer manager features (recreate, getLogs, …) are feature-detected with graceful fallbacks. Its type contract is validated against signalk-container ≥ 1.23.2 (1.23.0 first published the signalk-container/types entrypoint; 1.23.1 added the update-service types; 1.23.2 completed their option types); this is a dev-only check and imposes no dependency on your plugin.

Quick start: a managed container

For plugins that own their container's lifecycle (the signalk-backup / mayara archetype):

import { ManagedContainer, startSafely } from "signalk-container-helper";

export default function plugin(app) {
  let container: ManagedContainer | null = null;
  let settings = null;

  const plugin = {
    id: "signalk-myservice",
    name: "My Service",

    // Signal K does NOT await start() — keep it synchronous and let
    // startSafely catch and report async failures.
    start(rawConfig) {
      settings = { ...SCHEMA_DEFAULTS, ...rawConfig }; // SK doesn't seed defaults

      container = new ManagedContainer({
        app,
        pluginId: "signalk-myservice",
        name: "myservice", // unprefixed; runtime name is sk-myservice
        image: "ghcr.io/example/myservice",
        defaultTag: "latest",
        buildConfig: (tag) => ({
          image: "ghcr.io/example/myservice",
          tag,
          signalkAccessiblePorts: [9000], // let signalk-container wire networking
          env: { LOG_LEVEL: "info" },
          restart: "unless-stopped",
          resources: {
            cpus: 1,
            memory: "512m",
            memorySwap: "512m",
            pidsLimit: 100,
          },
        }),
        readiness: { port: 9000, path: "/api/health" },
        updates: {
          versionSource: { githubReleases: "example/myservice" },
          currentTag: () => settings?.imageTag ?? "latest",
        },
      });

      startSafely(app, async () => {
        const { address } = await container.start(settings.imageTag);
        // address = "http://127.0.0.1:9000" — the app answered /api/health
        app.setPluginStatus("Running");
      });
    },

    async stop() {
      await container?.stop(); // unregister updates + stop (not remove); never throws
      app.setPluginStatus("Stopped");
    },

    registerWithRouter(router) {
      // GET  /plugins/signalk-myservice/api/update/check
      // POST /plugins/signalk-myservice/api/update/apply   { tag?: string }
      container?.registerUpdateRoutes(router, {
        onApplied: (requestedTag) => {
          // persist the REQUESTED tag (e.g. "auto") so auto-tracking survives
          settings.imageTag = requestedTag;
          app.savePluginOptions(settings, () => undefined);
        },
      });
    },

    schema: () => SCHEMA,
  };
  return plugin;
}

What start() does for you, in order:

  1. Waits for the manager — polls globalThis.__signalk_containerManager (plugins start alphabetically; signalk-container may load after you), then waits for runtime detection to settle via whenReady(). Distinct, actionable errors for "signalk-container missing" vs "no podman/docker found".
  2. Validates the tag against /^[a-zA-Z0-9._-]+$/ and applies your resolveTag mapping (e.g. "auto" → a pinned tested version).
  3. Self-heals — if the live container's image differs from the desired image:tag, it is recreated immediately (signalk-container ≥ 1.12.0) instead of waiting on drift detection.
  4. Reconciles via ensureRunning(name, buildConfig(tag)) — declarative and idempotent; signalk-container recreates on config drift. No hash files.
  5. Registers for update detection (non-fatal on failure).
  6. Resolves the address for your readiness.port — with a fallback that parses listContainers() port bindings, because resolveContainerAddress can return a stale port after recreates.
  7. Waits for HTTP readiness — "container running" ≠ "app ready".

Progress is reported through app.setPluginStatus; the final "Running" message is yours to set. Fatal failures throw a typed ContainerHelperError after reporting via app.setPluginError — startSafely knows not to double-report.

Mounting your plugin's own data directory

signalkDataMount does not mount your plugin's data directory. Signal K rewrites app.getDataDirPath() per plugin, and signalk-container resolves that field from its own app object — so you get <configRoot>/plugin-config-data/signalk-container/, not yours.

| You want | Use | | ------------------------------------------- | ------------------------------------------------ | | A private writable area in the SK data tree | signalkDataMount (signalk-container's) | | The whole SK config root | signalkConfigRootMount (incl. security.json) | | Your own plugin's data dir | resolveMount() + a volumes entry | | Is there a GPU / device on the host? | probeHostDevice() |

probeHostDevice(manager, path) answers whether the host has a device, which a plugin cannot determine itself — stat("/dev/dri") describes the plugin's own filesystem, and that is the Signal K container whenever Signal K is containerized:

import { probeHostDevice } from "signalk-container-helper";

const gpu = await probeHostDevice(manager, "/dev/dri");
if (gpu?.exists) {
  config.devices = ["/dev/dri"];
  config.groupAdd = gpu.groups; // names, resolved on the host
}

null means unknown (no runtime, or the host could not be read) and is deliberately distinct from { exists: false } — assume no device, but do not report it as absent. Throws ContainerHelperError('unsupported-manager') on signalk-container older than 1.30.0, so catch it if you would rather degrade than fail. Works on both Docker and rootless podman.

resolveMount translates any absolute host path into a mount the container runtime can actually bind, on bare-metal and containerized Signal K alike:

Because buildConfig is synchronous, resolve the mount inside startSafely before constructing the container — resolveMount failures then propagate through startSafely like any other startup error:

import {
  ManagedContainer,
  resolveMount,
  startSafely,
  waitForContainerManager,
} from "signalk-container-helper";

start(rawConfig) {
  startSafely(app, async () => {
    // ManagedContainer normally acquires the manager itself; here you need
    // it up front, because buildConfig cannot await.
    const { manager } = await waitForContainerManager();
    if (!manager) throw new Error("signalk-container is not installed");

    const data = await resolveMount(manager, {
      containerPath: "/data",
      hostPath: app.getDataDirPath(), // YOUR plugin's app
    });

    container = new ManagedContainer({
      app,
      pluginId: "signalk-myservice",
      name: "myservice",
      image: "ghcr.io/example/myservice",
      buildConfig: (tag) => ({
        image: "ghcr.io/example/myservice",
        tag,
        volumes: { "/data": data.source },
        // data.containerPath, NOT "/data" — see below
        command: ["service", "--config", `${data.containerPath}/config.yml`],
      }),
    });

    await container.start(rawConfig.imageTag);
    app.setPluginStatus("Running");
  });
}

Use data.containerPath for paths inside the container, not the mount point you asked for. When Signal K's data dir lives on a named volume, container runtimes cannot bind a subdirectory of it, so the mount root is the volume and subPath is the offset to your directory — containerPath has that already joined. For bind mounts subPath is "" and containerPath equals what you passed. Ignoring it works on bare-metal and bind-mounted Docker, then silently reads the wrong directory on a named-volume deployment.

Quick start: an adopted container

For plugins whose container is managed elsewhere (systemd Quadlet, external host) — the signalk-doctor / signalk-updater archetype. Register it for update notifications and probe its health over HTTP, but never touch its lifecycle:

import {
  AdoptedContainer,
  probeHttpHealth,
  startSafely,
} from "signalk-container-helper";

const ENGINE_URL = "http://127.0.0.1:3004";

const adopted = new AdoptedContainer({
  app,
  pluginId: "signalk-mytool",
  containerName: "mytool-server",
  image: "ghcr.io/example/mytool-server",
  currentTag: "latest", // what the deployment pins (OperatorIntent)
  currentVersion: async () => {
    // the app's honest version (RuntimeIdentity)
    const res = await fetch(`${ENGINE_URL}/api/health`);
    return ((await res.json()) as { version?: string }).version ?? null;
  },
  versionSource: { githubReleases: "example/mytool-server" }, // LatestAvailable
  checkInterval: "24h",
});

// in start():
startSafely(app, async () => {
  // false + setPluginError when unavailable; never throws. Stop here so we
  // don't overwrite that error with a health status below.
  if (!(await adopted.register())) return;

  const probe = await probeHttpHealth(`${ENGINE_URL}/api/health`);
  if (!probe.reachable) {
    app.setPluginError(
      "mytool-server is not reachable — is its service running?",
    );
  } else if (probe.slowMs) {
    // Signal K has no warning tier — report slow-but-healthy as a status
    app.setPluginStatus(
      `Reachable but slow (${probe.slowMs}ms) — likely disk I/O contention`,
    );
  } else {
    app.setPluginStatus("Running");
  }
});

// in stop():
adopted.unregister();

Why not manager.getState() for health? signalk-container namespace-prefixes the containers it manages (sk-<name>); externally-managed peers don't carry the prefix, so the manager can't see them — and "running" isn't "healthy" anyway.

Managed or self-hosted

Some operators would rather run the service themselves — on a NAS, another Pi, or a box that is already doing the job — and point the plugin at it. That is a config switch (managedContainer) plus an address, and every plugin that offers it had written its own.

Scope, precisely: this means the service runs elsewhere. It does not mean a container engine elsewhere — signalk-container talks to local unix sockets only and rejects a tcp:// endpoint, so there is no remote-engine mode to opt into.

import {
  resolveEndpoint,
  waitForEndpointReady,
  isManagedMode,
} from "signalk-container-helper";

const endpoint = await resolveEndpoint({
  managed: settings.managedContainer, // pass the RAW config value
  externalUrl: settings.externalUrl,
  container, // a started ManagedContainer; managed mode only
  port: 3020,
  productName: "signalk-tailscale-server",
});

await waitForEndpointReady(endpoint, { path: "/api/health" });
const client = new ShimClient(endpoint.baseUrl);

endpoint.baseUrl is scheme-ful with no trailing slash in both modes, so the transport half of your plugin stops caring which mode it is in. endpoint.mode is there for the parts that legitimately differ.

If you drive ensureRunning yourself

container is for plugins that hand their lifecycle to ManagedContainer. Plenty do not — calling ensureRunning/pullImage/stop directly is a perfectly good pattern, and address resolution needs none of that lifecycle. Give a containerName instead:

const endpoint = await resolveEndpoint({
  managed: settings.managedContainer,
  externalUrl: settings.externalUrl,
  containerName: CONTAINER_NAME, // unprefixed
  manager, // optional; defaults to the globalThis one
  port: API_PORT,
  productName: "signalk-tailscale-server",
  debug: (msg) => app.debug(msg),
});

endpoint.container is null in that form. Use endpoint.mode to decide managed-versus-external behaviour, and null-check container separately before touching it — mode === "managed" does not imply a container is there.

resolveContainerEndpoint(manager, name, port, debug?) is exported on its own if you only want the address. It asks the manager first and falls back to parsing listContainers() port bindings — the resolver's process-local cache can go stale after a recreate, and it throws (rather than returning null) when the port was declared in signalkAccessiblePorts but ensureRunning() has not run yet. Both are handled; it never throws.

It also matches the live container by unprefixedName, falling back to any <namespace>-<name> form. A hand-rolled sk-<name> comparison — which is what the copies of this logic tended to be — silently finds nothing under a non-default SIGNALK_CONTAINER_NAMESPACE.

Pass the raw config value

managed is boolean | undefined, and undefined means managed. Signal K calls plugin.start() with {} when a plugin is enabled without its form ever being saved, so Boolean(config.managedContainer) would flip exactly those installs into self-hosted mode with an empty URL — a plugin that used to work now errors on enable. Use isManagedMode(...) anywhere you need the mode question on its own (route guards, status fields).

Readiness retries are opt-in

waitForEndpointReady makes one attempt unless you pass retry. The reference plugins genuinely differ here — one retries forever because an unattended boat has to heal itself, another reports once and waits for the operator — so neither behaviour is imposed:

await waitForEndpointReady(endpoint, {
  path: "/api/health",
  signal,
  retry: {
    minDelayMs: 15_000,
    onAttemptFailed: (err, next) =>
      app.setPluginError(
        `${endpoint.baseUrl} unreachable: ${errMsg(err)} — retrying in ${Math.round(next / 1000)}s`,
      ),
  },
});

The config fields

managedModeSchema emits the two form fields as plain JSON Schema. It has to be plain: consumers are split between typebox 1.x and @sinclair/typebox 0.34, which are mutually incompatible, and this library depends on neither.

Splice each fragment with Type.Unsafe. The call is written identically in both packages:

import { Type, type Static } from "@sinclair/typebox"; // or "typebox"
import { managedModeSchema } from "signalk-container-helper/schema";

const MODE = managedModeSchema({
  productName: "signalk-tailscale-server",
  image: "ghcr.io/dirkwa/signalk-tailscale-server",
  exampleUrl: "http://192.168.1.50:3020",
});

export const ConfigSchema = Type.Object({
  managedContainer: Type.Unsafe<boolean>(MODE.managedContainer),
  externalUrl: Type.Unsafe<string>(MODE.externalUrl),
  // …your own fields
});

export const SCHEMA_DEFAULTS: Config = {
  ...MODE.defaults, // { managedContainer: true, externalUrl: "" }
  // …your own defaults
};

Hiding the URL field when it does not apply

By default the URL field is always rendered, including while the container is managed — where it does nothing. Splice in dependencies to have it appear only when the toggle is off:

import {
  managedModeSchema,
  type WithExternalUrl,
} from "signalk-container-helper/schema";

// NOTE: externalUrl is deliberately NOT in `properties` — see below.
export const ConfigSchema = Type.Object(
  {
    managedContainer: Type.Unsafe<boolean>(MODE.managedContainer),
    // …your own fields
  },
  { dependencies: MODE.dependencies },
);

export type Config = WithExternalUrl<Static<typeof ConfigSchema>>;

externalUrl must be left out of properties. RJSF renders everything in properties regardless of what the dependency says, so leaving it there means the field never hides — the dependency simply has no effect.

That has a consequence worth knowing: TypeBox derives Static<> from properties alone, so the field would drop out of your Config type and every settings.externalUrl read would stop compiling. WithExternalUrl adds it back. Pass the field name as a second argument when it is not externalUrl:

export type Config = WithExternalUrl<Static<typeof S>, "serverUrl">;

SCHEMA_DEFAULTS is unaffected — ...MODE.defaults still supplies both keys.

This is live — RJSF re-evaluates dependencies on every change, so the field appears the instant the toggle is switched off. A uiSchema ui:disabled cannot do that: a plugin's uiSchema is fetched once when the form loads, so a greyed-out field would stay greyed until a page reload.

Optional. Omit dependencies to keep the field always visible.

Type.Unsafe is not optional. A bare fragment spread straight into Type.Object({...}) compiles under typebox 1.x and fails under @sinclair/typebox 0.34 (missing the following properties from type 'TSchema': params, static, [Kind]).

Two things to know about it. It asserts the static type rather than deriving it, so keep the type argument and the fragment's type in step — nothing checks that for you. And Type.Unsafe emits the same keys and values as Type.Boolean/Type.String but in a different order (type first rather than last); that is meaningless to JSON Schema and to the Admin UI's form renderer, so compare with sorted keys if you assert on the emitted schema while migrating.

Why not just move every plugin to typebox 1.x?

Because it would not remove the split. @signalk/server-api depends on @sinclair/typebox 0.34 itself, so the scoped package sits in every consumer's dependency tree regardless of which one the plugin picks for its own schema — signalk-questdb already resolves both today. Migrating a plugin adds a second TypeBox beside the first rather than replacing it.

Migrating is still reasonable on its own merits: @sinclair/typebox is frozen at 0.34.52 (TypeBox 1.x was republished under the unscoped typebox name instead of taking a major bump), so plugins on the scoped package are on a line that receives no further fixes. For the constructs these plugins actually use — Object, String, Boolean, Number, Literal, Union, Array — the two versions emit semantically identical JSON Schema (verified; only key order differs), and Type/Static import the same way, so it is a per-plugin import swap rather than a port.

What it is not is a prerequisite for this API. Emitting plain JSON Schema keeps this module out of the question entirely, and works before, during and after any such migration.

Spreading MODE.defaults matters because Signal K uses a schema default only to seed the form, never the config object a plugin receives — which is why every consumer hand-writes a SCHEMA_DEFAULTS beside its schema, and why those two drift.

managedContainer means something else in two plugins

signalk-doctor and signalk-updater also have a managedContainer option, but it defaults to false and means "a systemd Quadlet owns this local container" — not "the service is on another host". It is a name collision, not a variant of this feature: those plugins have no remote endpoint, and AdoptedContainer is what already models them. Do not wire them to resolveEndpoint.

A self-hosted upstream has to be resolved, not assumed

A consumer that hardcodes a container DNS name (sk-<name>:<port>) to reach a peer will break when that peer is self-hosted — and, in fact, already breaks on a bare-metal or shared-netns host, since signalk-container picks between loopback and container DNS depending on the topology. Resolve peer addresses rather than assuming one.

Config-panel UI (signalk-container-helper/ui)

The reference plugins also share a hand-copied React config panel: a container status card, an image-version dropdown, a check/apply update row, and the same inline-style vocabulary. The /ui entrypoint packages those as reusable components and hooks. It is a separate subpath export — the main entry stays Node-only and zero-dependency; /ui needs react (an optional peer dependency) and runs in the Signal K Admin UI page.

How it fits the standard panel build

Signal K loads plugin config panels as webpack Module Federation remotes (public/remoteEntry.js exposing ./PluginConfigurationPanel, with react shared as a singleton). The standard webpack config runs babel-loader with exclude: /node_modules/, so a library shipping raw JSX would break the build. This entrypoint therefore ships tsc-compiled React.createElement JS (no JSX) that webpack bundles directly, and its import "react" resolves to the host Admin UI's shared React singleton. No loader changes are needed — but the remote's output format must match your package's module type (next section).

npm install signalk-container-helper react

(react can be a devDependency of your plugin — it is only used at bundle time; at runtime the Admin UI provides the singleton.)

ESM plugins must build an ESM remote

The server injects each panel's script tag based on the plugin's package.json type field:

moduleInfo.type === "module"
  ? `<script type="module" src="/${moduleInfo.name}/remoteEntry.js"></script>`
  : `<script src="/${moduleInfo.name}/remoteEntry.js"></script>`;

The remote's output format must match, and the failure modes on a mismatch are unhelpful, so get this right up front:

  • CommonJS plugin (the reference plugins): the classic remote — library: { type: "var", name: "<name with [-@/] → _>" } — which lands on window when the plain script tag runs.

  • "type": "module" plugin (likely yours — this library is ESM-only): the Admin UI loads the panel with a dynamic import() and requires real get/init module exports. A copied var remote loads silently into module scope — no window global, no exports — and the panel dies with 'Module "…" is not available. Make sure the webapp is installed.' even though discovery and serving worked. Build an ESM container instead:

    // webpack.config.cjs (an ESM package needs the .cjs extension, or an ESM config)
    module.exports = {
      // ...
      experiments: { outputModule: true },
      output: {
        path: path.resolve(__dirname, "public"),
        module: true,
        clean: false,
      },
      plugins: [
        new ModuleFederationPlugin({
          name: "your_plugin_name",
          library: { type: "module" }, // no global var name
          filename: "remoteEntry.js",
          exposes: {
            "./PluginConfigurationPanel":
              "./src/configpanel/PluginConfigurationPanel",
          },
          shared: {
            react: { singleton: true, requiredVersion: "^19" },
            "react-dom": { singleton: true, requiredVersion: "^19" },
          },
        }),
      ],
    };

    Chunks emit as .mjs; verify the container shape with node -e 'import("./public/remoteEntry.js").then((m) => console.log(typeof m.get, typeof m.init))' (both must print function).

The mismatch breaks in the other direction too: a CJS package emitting an ESM remote throws a SyntaxError when the browser executes export syntax from a plain script tag. A working ESM example is signalk-piper's webpack.config.cjs.

Example panel

import React, { useState } from "react";
import {
  panelStyles as S,
  SectionTitle,
  StatusCard,
  FieldRow,
  VersionSelect,
  UpdateControls,
  CollapsibleSection,
  ActionStatus,
  Button,
  useStatusPoll,
  useVersions,
} from "signalk-container-helper/ui";

const BASE = "/plugins/signalk-myservice";

export default function PluginConfigurationPanel({ configuration, save }) {
  const cfg = configuration || {};
  const [tag, setTag] = useState(cfg.imageTag || "latest");
  const [saved, setSaved] = useState("");

  // Polls /api/status every 5s; parses non-2xx bodies too (unhealthy
  // responses often carry fields the panel must surface).
  const { status, loading } = useStatusPoll(`${BASE}/api/status`, {
    fallback: { status: "not_running" },
  });

  // Fetches /api/versions once; refresh() from the ↻ button. Accepts a bare
  // VersionInfo[] or the structured { versions, sources } shape, and keeps
  // the last known list when the fetch fails.
  const versions = useVersions(`${BASE}/api/versions`);

  const running = status?.status === "running";
  return (
    <div style={S.root}>
      <SectionTitle>My Service Status</SectionTitle>
      <StatusCard
        icon="M"
        iconBackground={running ? "#7c3aed" : undefined}
        title="My Service"
        meta={
          loading ? "Checking..." : running ? status.endpoint : "Not running"
        }
        state={running ? "ok" : "error"}
        link={running ? { href: status.url, label: "Open ↗" } : undefined}
      />

      {/* Talks to the routes ManagedContainer.registerUpdateRoutes mounts. */}
      {running && (
        <UpdateControls
          checkUrl={`${BASE}/api/update/check`}
          applyUrl={`${BASE}/api/update/apply`}
          tag={tag}
        />
      )}

      <SectionTitle>Settings</SectionTitle>
      <FieldRow label="Image version">
        <VersionSelect
          value={tag}
          onChange={setTag}
          versions={versions.versions}
          loading={versions.loading}
          error={versions.versionsError}
          onRefresh={versions.refresh}
        />
      </FieldRow>

      <CollapsibleSection title="Advanced">
        <FieldRow label="Extra arguments" hint="rarely needed">
          <input style={S.input} />
        </FieldRow>
      </CollapsibleSection>

      <ActionStatus message={saved} />
      <div style={{ marginTop: 24 }}>
        <Button
          onClick={() => {
            save({ ...cfg, imageTag: tag });
            setSaved("Saved! Plugin will restart with new configuration.");
          }}
        >
          Save Configuration
        </Button>
      </div>
    </div>
  );
}

UI exports

| Export | Purpose | | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | panelStyles, stateColors | The shared inline-style vocabulary (root, card, fieldRow, input, warnBanner, …). Inline styles are deliberate: a CSS file shipped by a federation remote would leak into or be clobbered by the host page. Spread-extend: { ...panelStyles.input, width: 300 } | | StatusCard, StateDot | Container status card (icon, title, meta, optional link, state dot) and the bare green/amber/red dot | | VersionSelect | Image-version dropdown: floating tags (latest, main, …), pre-releases, stable releases, PR test images — and a synthetic <tag> (running) option when the current value isn't listed, so the controlled select never silently resets the running image | | UpdateControls | Self-contained check/apply row against registerUpdateRoutes' contract (GET check → UpdateCheckResult, POST apply → { success, tag }) | | SectionTitle, FieldRow, Hint, CollapsibleSection, Button, ActionStatus | Form scaffolding: uppercase section headings, label + control + hint rows, collapsed-by-default advanced sections (keyboard-accessible), busy-aware buttons, the green/red outcome line | | useStatusPoll(url, opts) | Self-scheduling status poll (no overlapping requests on slow hosts; stale responses dropped; body parsed on non-2xx too) | | useVersions(url) | /api/versions fetch with rate-limit/offline error lines; preserves the last known list on failure | | useUpdateFlow({ checkUrl, applyUrl }) | The check/apply state machine behind UpdateControls, for custom layouts | | splitVersions, shownTags, runningTagFallback, deriveVersionsView | The pure dropdown view-logic (unit-tested without a DOM) | | formatUpdateMessage, formatTimeAgo, formatNumber | UpdateCheckResult → status line ("Update available: 1.0.0 → 1.1.0", "Offline — last checked 3h ago…"), relative timestamps, compact counts ("1.2K") |

Panel conventions the components encode

Adopt these even where you don't use the components:

  • A controlled <select> must always render its value. If the running tag isn't in the options (a GitHub rate limit hid it, or a pin fell out of the top-N), inject a synthetic option — otherwise the browser shows the first option and the next Save silently changes the running image.
  • Version lists degrade, never wipe. On a failed /api/versions fetch, keep showing the last known list with an explanatory error line.
  • Poll by self-scheduling, not setInterval. On a slow host one response can outlast the poll period; the next request must only start after the previous one settled. Drop stale responses with a generation counter.
  • Parse status bodies on non-2xx. Unhealthy responses (503) often carry the very fields the operator needs to fix the problem.
  • Offline is a state, not an error. Boats lose connectivity; show "last checked 3h ago", don't paint the panel red.
  • Persist the requested tag, not the resolved one after an update, so floating tags like latest keep auto-tracking.
  • Match the remote's module format to your package's type field. The server injects <script type="module"> for "type": "module" plugins and a plain script tag otherwise; the wrong webpack library type fails only at panel-open time, with a misleading "webapp is not installed" error (see ESM plugins must build an ESM remote).

API overview

| Export | Purpose | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ManagedContainer | Full lifecycle: start, stop, applyUpdate, checkForUpdate, getState, getStateDetail, getInfo, resolveAddress, getLogs, registerUpdateRoutes | | AdoptedContainer | Update registration + checks for externally-managed containers | | getContainerManager() | Read the globalThis.__signalk_containerManager global | | waitForContainerManager(opts) | Two-phase wait (manager present → runtime settled); returns { manager, runtime } so the two failure modes get distinct messages | | resolveMount(manager, opts) | Translate an absolute host path into a mountable { source, containerPath, subPath } — how you mount your own plugin's data dir (see Mounting your plugin's own data directory) | | resolveEndpoint(opts) | Resolve the base URL for a managed container or a self-hosted service — pass the RAW managedContainer value (undefined means managed) | | waitForEndpointReady(ep, opts) | Wait until a resolved endpoint answers 2xx. One attempt unless retry is given | | isManagedMode(value) | The !== false rule, named — for route guards and status fields | | resolveContainerEndpoint(manager, name, port, debug?) | host:port for a container port, or null. Resolver first, then listContainers() port bindings; never throws | | matchContainerInfo(list, name) | Find the live container for an unprefixed managed name, honouring unprefixedName and any namespace prefix | | normalizeExternalUrl(raw) | Normalise operator-typed input into a base URL (scheme defaulted, trailing slash stripped, path discarded); null when unusable | | managedModeSchema(opts) | The two config fields as plain JSON Schema + matching defaults (signalk-container-helper/schema) | | waitForHttpReady(url, opts) | Poll until 2xx or deadline (throws) | | retryForever(fn, opts) | Retry until success — 15s doubling to a 120s ceiling, no attempt cap. ManagedContainer takes it as readinessRetry; exported standalone for work this library does not manage | | anySignal(signals) | Compose several AbortSignals into one that aborts when any does (undefined when none are given) | | probeHttpHealth(url, opts) | Retrying liveness probe with slow-response detection (never throws) | | fetchWithTimeout(url, opts) | fetch with an AbortController timeout | | throwIfAborted(signal) | Throw ContainerHelperError cancelled if the signal has fired — the check the lifecycle methods run between steps | | startSafely(app, fn) | Sync wrapper for async plugin startup — Signal K does not await start() | | isValidImageTag(tag) | Tag guard (IMAGE_TAG_PATTERN) | | errMsg(err) | Normalize unknown errors to strings | | ContainerHelperError | Typed error with code and reported | | Types | Local mirror of signalk-container's public API — ContainerManagerApi, ContainerConfig, EnsureRunningOptions, UpdateServiceApi, … — verified at build time against signalk-container/types (≥ 1.23.2) so it never silently drifts. Feature-detected members stay optional here. |

Error codes

ContainerHelperError.code values thrown by start() / applyUpdate():

| Code | Meaning | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | manager-unavailable | signalk-container never published its API within the budget | | no-runtime | Manager present, but no podman/docker was detected | | invalid-tag | Tag failed the IMAGE_TAG_PATTERN guard | | address-unresolved | No host:port could be found for the readiness port | | not-ready | The app never answered its health URL before the deadline | | recreate-limbo | Legacy update path removed the container but recreation failed — retry applies | | cancelled | The AbortSignal passed to the operation fired, or a newer lifecycle operation superseded a retrying start() (see Cancelling an operation) | | invalid-option | An option could not produce sane behaviour — e.g. a non-finite or negative retryForever delay bound | | unsupported-manager | resolveMount needs resolveHostPath (signalk-container 1.7.0+) | | path-unreachable | resolveMount found no Signal K mount covering the requested host path, so the host runtime cannot reach it |

Errors thrown by the lifecycle methods have already been surfaced through app.setPluginError (reported: true), so startSafely won't report them twice. resolveMount is the exception: it takes no app, so its errors carry reported: false and startSafely is what surfaces them.

Cancelling an operation

start() and applyUpdate() take an optional second argument carrying an AbortSignal:

const abort = new AbortController();

// in start():
startSafely(app, () =>
  container.start(settings.imageTag, { signal: abort.signal }),
);

// in stop():
abort.abort(); // unblocks an in-flight start
await container.stop();

stop() takes no signal: it has nothing cancellable — an unregister call and one uninterruptible manager.stop — so accepting one would promise what it cannot deliver. It is still serialized against the other operations.

An aborted operation rejects with ContainerHelperError code cancelled, already flagged reported, so startSafely logs it at debug rather than putting a plugin error on screen for something you asked for.

Cancellation is cooperative, not pre-emptive. signalk-container's ensureRunning, recreate and stop take no signal — only its one-off job API does — so a call already in flight runs to completion. What the signal cancels is everything around it: waiting for the manager global, the drift probe, readiness polling, and each step boundary. That is where the time actually goes, since those are the polls with deadlines measured in minutes, and it is what stops an abandoned start from continuing to work against a container you have already torn down.

Operations are also serialized per instance. An overlapping start and stop — a plugin restarted while its first start is still waiting on readiness — queue rather than interleave, so stop can no longer land between ensureRunning and the readiness poll and leave the plugin believing it started something it just removed. Callers that already hold their own lifecycle lock see no change.

Retrying forever

start() throws once when bring-up fails, which is right for a plugin that reports the problem and waits for a human. Where no human may be coming, pass readinessRetry:

const container = new ManagedContainer({
  // …
  readinessRetry: {
    onAttemptFailed: (err, nextDelayMs) =>
      app.setPluginError(
        `Backup server unreachable: ${errMsg(err)} — retrying in ${Math.round(nextDelayMs / 1000)}s`,
      ),
  },
});

// Resolves only on success; rejects on cancellation, on an invalid delay
// bound, or if your onAttemptFailed callback itself throws.
const { address } = await container.start(tag, { signal });

start() then retries the whole bring-up — 15s doubling to a 120s ceiling, indefinitely. A container that lost a boot race should not stay down until someone restarts the plugin; on a boat that can be weeks. Each attempt re-runs start() whole, which is safe because ensureRunning is idempotent, and necessary because a container that only just came up may bind a different host port than the last attempt saw.

Use onAttemptFailed to keep the status line honest — the returned promise stays pending either way, so without it an operator cannot tell "still retrying" from "stuck".

Pair it with a signal: cancellation is the only exit besides success. Both the per-call signal and readinessRetry.signal are honoured — aborting either ends the loop, and an abort during a backoff settles immediately rather than waiting the delay out.

Each attempt is serialized individually rather than the loop as a whole, so a retry sleeping between attempts never blocks stop(). A later lifecycle operation also retires the loop: a start() still retrying an old tag stands down when applyUpdate() or stop() runs, instead of restarting the container on the image the operator just moved away from.

retryForever is exported on its own for the same policy applied to work this library does not manage — an external service a plugin points at but does not own, for instance.

Version compatibility

The helpers feature-detect newer signalk-container capabilities:

| Capability | Floor | Fallback behavior | | ------------------------------------------------------ | ------ | ------------------------------------------------------------ | | whenReady() | 1.6.0 | polls getRuntime() | | getLogs() | 1.7.0 | getLogs() returns null | | resolveHostPath() | 1.7.0 | resolveMount() throws unsupported-manager | | recreate() | 1.12.0 | self-heal skipped; updates use pull → remove → ensureRunning | | ContainerConfig.healthcheck | 1.14.0 | ignored by older versions | | ContainerConfig.ulimits | 1.17.0 | ignored by older versions | | ContainerConfig.devices / ContainerConfig.groupAdd | 1.24.0 | ignored by older versions | | getStateDetail() | 1.31.0 | falls back to getState(); extra fields undefined |

Design rules inherited from the reference plugins

  • Runtime-only coupling. Never import signalk-container; reach it through the global. The types shipped here are a mirror, not a dependency.
  • Never throw out of start(). The server doesn't await it — use startSafely.
  • Stop, don't remove. stop() leaves the container in place so re-enabling the plugin restarts it instantly without a pull.
  • Offline is normal. Boats at sea lose connectivity; nothing here converts a network failure into a fatal error.
  • The user owns updates. Update detection notifies; applying is an explicit action (applyUpdate / the POST route). Persist the requested tag (e.g. "auto"), not the resolved version, so auto-tracking survives restarts.

Development

npm install
npm test          # typecheck the type contract, then vitest (fully mocked — no containers needed)
npm run build     # tsc → dist/
npm run format    # prettier --write + eslint --fix
npm run ci-lint   # eslint + prettier --check (what CI runs)

CI (.github/workflows/ci.yml) runs ci-lint, build, and test on every push and pull request.

Releasing

This library is distributed through npm with semver — consumers npm install signalk-container-helper and pin a range (^1.0.0); they never build against master. master is the development trunk and may be mid-change without affecting anyone.

Releases are tag-triggered (.github/workflows/publish.yml fires on v* tags):

  1. Bump version in package.json, commit, and merge to master.
  2. Run npm run release — it tags v<version> and pushes the tag.
  3. The workflow creates a GitHub Release whose notes are generated from the PRs merged since the previous tag (grouped by .github/release.yml), then builds, tests, and runs npm publish --provenance (tags containing alpha/beta/rc publish under the matching dist-tag).

Publishing uses npm trusted publishing (OIDC) — no npm token secret, but the package must list this repo's workflow as a trusted publisher on npmjs.com.

License

Apache-2.0