From b5dede248afa836e37282179d0c8163c1a5a879a Mon Sep 17 00:00:00 2001 From: Teppo Kurki Date: Thu, 8 Jan 2026 21:58:00 +0200 Subject: [PATCH] feat: Add WebAssembly plugin support with Component Model - Add AssemblyScript plugin SDK for WASM plugin development - Support WASM plugins written in Rust, Go, .NET, and AssemblyScript - Implement Component Model bindings for SignalK API - Add plugin lifecycle management (load, start, stop, unload) - Support multiple WASM formats (Component Model, standard WASM) - Add TCP socket API for network-capable WASM plugins - Implement plugin configuration storage via plugin-config-data - Support WASM plugin HTTP endpoints and webapps - Add comprehensive documentation and example plugins - Fix Asyncify race conditions for network-capable WASM plugins - Make WASM runtime toggleable via server settings --- .github/workflows/release.yml | 10 + .gitignore | 18 + .npmignore | 19 + .prettierignore | 5 + README.md | 6 +- docs/develop/plugins/README.md | 12 + docs/develop/plugins/wasm/README.md | 155 +++ docs/develop/plugins/wasm/assemblyscript.md | 614 ++++++++++ docs/develop/plugins/wasm/best_practices.md | 234 ++++ docs/develop/plugins/wasm/capabilities.md | 414 +++++++ docs/develop/plugins/wasm/deltas.md | 193 +++ docs/develop/plugins/wasm/dotnet.md | 493 ++++++++ docs/develop/plugins/wasm/go.md | 238 ++++ docs/develop/plugins/wasm/http_endpoints.md | 183 +++ .../develop/plugins/wasm/integration_guide.md | 400 +++++++ docs/develop/plugins/wasm/rust.md | 354 ++++++ docs/internal/README.md | 17 + docs/internal/wasm-architecture.md | 103 ++ docs/internal/wasm-asyncify.md | 391 ++++++ eslint.config.js | 14 +- .../example-anchor-watch-dotnet/.gitignore | 24 + .../AnchorWatch.csproj | 50 + .../example-anchor-watch-dotnet/PluginImpl.cs | 221 ++++ .../example-anchor-watch-dotnet/Program.cs | 591 ++++++++++ .../example-anchor-watch-dotnet/README.md | 700 +++++++++++ .../example-anchor-watch-dotnet/build.sh | 61 + .../example-anchor-watch-dotnet/nuget.config | 10 + .../package-lock.json | 23 + .../example-anchor-watch-dotnet/package.json | 47 + .../scripts/patch-jco-output.js | 49 + .../example-anchor-watch-dotnet/test-jco.mjs | 17 + .../wit/signalk-plugin.wit | 52 + .../example-anchor-watch-rust/.gitignore | 18 + .../example-anchor-watch-rust/.npmignore | 27 + .../example-anchor-watch-rust/Cargo.toml | 22 + .../example-anchor-watch-rust/README.md | 395 +++++++ .../example-anchor-watch-rust/package.json | 39 + .../example-anchor-watch-rust/src/lib.rs | 553 +++++++++ .../wit/signalk-plugin.wit | 52 + .../example-hello-assemblyscript/.npmignore | 2 + .../example-hello-assemblyscript/README.md | 205 ++++ .../asconfig.json | 24 + .../assembly/index.ts | 363 ++++++ .../package-lock.json | 139 +++ .../example-hello-assemblyscript/package.json | 31 + .../example-hello-assemblyscript/plugin.d.ts | 63 + .../example-hello-assemblyscript/plugin.js | 114 ++ .../example-routes-waypoints/.npmignore | 12 + .../example-routes-waypoints/README.md | 222 ++++ .../example-routes-waypoints/asconfig.json | 24 + .../assembly/index.ts | 602 ++++++++++ .../build/plugin.d.ts | 57 + .../example-routes-waypoints/build/plugin.js | 121 ++ .../example-routes-waypoints/package.json | 38 + .../example-weather-plugin/.npmignore | 2 + .../example-weather-plugin/README.md | 107 ++ .../example-weather-plugin/asconfig.json | 25 + .../example-weather-plugin/assembly/index.ts | 542 +++++++++ .../example-weather-plugin/build/plugin.d.ts | 45 + .../example-weather-plugin/build/plugin.js | 198 ++++ .../example-weather-plugin/package-lock.json | 249 ++++ .../example-weather-plugin/package.json | 41 + .../example-weather-provider/.npmignore | 12 + .../example-weather-provider/README.md | 188 +++ .../example-weather-provider/asconfig.json | 25 + .../assembly/index.ts | 523 +++++++++ .../build/plugin.d.ts | 51 + .../example-weather-provider/build/plugin.js | 204 ++++ .../package-lock.json | 249 ++++ .../example-weather-provider/package.json | 40 + package.json | 23 +- packages/assemblyscript-plugin-sdk/.npmignore | 1 + packages/assemblyscript-plugin-sdk/LICENSE | 201 ++++ packages/assemblyscript-plugin-sdk/README.md | 40 + .../assemblyscript-plugin-sdk/asconfig.json | 25 + .../assemblyscript-plugin-sdk/assembly/api.ts | 287 +++++ .../assembly/index.ts | 15 + .../assembly/network.ts | 53 + .../assembly/plugin.ts | 49 + .../assembly/resources.ts | 157 +++ .../assembly/signalk.ts | 153 +++ .../build/plugin.d.ts | 104 ++ .../assemblyscript-plugin-sdk/build/plugin.js | 232 ++++ .../assemblyscript-plugin-sdk/package.json | 51 + .../src/views/Configuration/Configuration.js | 68 +- .../src/views/ServerConfig/Settings.js | 3 +- packages/server-api/package.json | 2 +- packages/server-api/wit/signalk.wit | 98 ++ src/config/config.ts | 1 + src/index.ts | 98 +- src/interfaces/plugins.ts | 93 +- src/interfaces/wasm.ts | 47 + src/interfaces/webapps.js | 13 +- src/modules.ts | 21 + src/pluginid.ts | 20 + src/serverroutes.ts | 34 +- src/wasm/bindings/binary-stream.ts | 95 ++ src/wasm/bindings/env-imports.ts | 1043 +++++++++++++++++ src/wasm/bindings/index.ts | 9 + src/wasm/bindings/radar-provider.ts | 952 +++++++++++++++ src/wasm/bindings/resource-provider.ts | 262 +++++ src/wasm/bindings/signalk-api.ts | 77 ++ src/wasm/bindings/socket-manager.ts | 830 +++++++++++++ src/wasm/bindings/weather-provider.ts | 341 ++++++ src/wasm/index.ts | 81 ++ src/wasm/loader/index.ts | 61 + src/wasm/loader/plugin-config.ts | 140 +++ src/wasm/loader/plugin-lifecycle.ts | 472 ++++++++ src/wasm/loader/plugin-registry.ts | 423 +++++++ src/wasm/loader/plugin-routes.ts | 660 +++++++++++ src/wasm/loader/types.ts | 56 + src/wasm/loaders/component-loader.ts | 205 ++++ src/wasm/loaders/index.ts | 7 + src/wasm/loaders/jco-loader.ts | 247 ++++ src/wasm/loaders/standard-loader.ts | 552 +++++++++ src/wasm/types.ts | 112 ++ src/wasm/utils/fetch-wrapper.ts | 92 ++ src/wasm/utils/format-detection.ts | 39 + src/wasm/utils/index.ts | 6 + src/wasm/wasm-runtime.ts | 286 +++++ src/wasm/wasm-serverapi.ts | 393 +++++++ src/wasm/wasm-storage.ts | 278 +++++ src/wasm/wasm-subscriptions.ts | 281 +++++ test/wasm-plugin-test-config/package.json | 7 + test/wasm-plugins.ts | 240 ++++ typedoc.json | 14 +- 126 files changed, 21410 insertions(+), 82 deletions(-) create mode 100644 docs/develop/plugins/wasm/README.md create mode 100644 docs/develop/plugins/wasm/assemblyscript.md create mode 100644 docs/develop/plugins/wasm/best_practices.md create mode 100644 docs/develop/plugins/wasm/capabilities.md create mode 100644 docs/develop/plugins/wasm/deltas.md create mode 100644 docs/develop/plugins/wasm/dotnet.md create mode 100644 docs/develop/plugins/wasm/go.md create mode 100644 docs/develop/plugins/wasm/http_endpoints.md create mode 100644 docs/develop/plugins/wasm/integration_guide.md create mode 100644 docs/develop/plugins/wasm/rust.md create mode 100644 docs/internal/README.md create mode 100644 docs/internal/wasm-architecture.md create mode 100644 docs/internal/wasm-asyncify.md create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/.gitignore create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/AnchorWatch.csproj create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/PluginImpl.cs create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/Program.cs create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/README.md create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/build.sh create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/nuget.config create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/package-lock.json create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/package.json create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/scripts/patch-jco-output.js create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/test-jco.mjs create mode 100644 examples/wasm-plugins/example-anchor-watch-dotnet/wit/signalk-plugin.wit create mode 100644 examples/wasm-plugins/example-anchor-watch-rust/.gitignore create mode 100644 examples/wasm-plugins/example-anchor-watch-rust/.npmignore create mode 100644 examples/wasm-plugins/example-anchor-watch-rust/Cargo.toml create mode 100644 examples/wasm-plugins/example-anchor-watch-rust/README.md create mode 100644 examples/wasm-plugins/example-anchor-watch-rust/package.json create mode 100644 examples/wasm-plugins/example-anchor-watch-rust/src/lib.rs create mode 100644 examples/wasm-plugins/example-anchor-watch-rust/wit/signalk-plugin.wit create mode 100644 examples/wasm-plugins/example-hello-assemblyscript/.npmignore create mode 100644 examples/wasm-plugins/example-hello-assemblyscript/README.md create mode 100644 examples/wasm-plugins/example-hello-assemblyscript/asconfig.json create mode 100644 examples/wasm-plugins/example-hello-assemblyscript/assembly/index.ts create mode 100644 examples/wasm-plugins/example-hello-assemblyscript/package-lock.json create mode 100644 examples/wasm-plugins/example-hello-assemblyscript/package.json create mode 100644 examples/wasm-plugins/example-hello-assemblyscript/plugin.d.ts create mode 100644 examples/wasm-plugins/example-hello-assemblyscript/plugin.js create mode 100644 examples/wasm-plugins/example-routes-waypoints/.npmignore create mode 100644 examples/wasm-plugins/example-routes-waypoints/README.md create mode 100644 examples/wasm-plugins/example-routes-waypoints/asconfig.json create mode 100644 examples/wasm-plugins/example-routes-waypoints/assembly/index.ts create mode 100644 examples/wasm-plugins/example-routes-waypoints/build/plugin.d.ts create mode 100644 examples/wasm-plugins/example-routes-waypoints/build/plugin.js create mode 100644 examples/wasm-plugins/example-routes-waypoints/package.json create mode 100644 examples/wasm-plugins/example-weather-plugin/.npmignore create mode 100644 examples/wasm-plugins/example-weather-plugin/README.md create mode 100644 examples/wasm-plugins/example-weather-plugin/asconfig.json create mode 100644 examples/wasm-plugins/example-weather-plugin/assembly/index.ts create mode 100644 examples/wasm-plugins/example-weather-plugin/build/plugin.d.ts create mode 100644 examples/wasm-plugins/example-weather-plugin/build/plugin.js create mode 100644 examples/wasm-plugins/example-weather-plugin/package-lock.json create mode 100644 examples/wasm-plugins/example-weather-plugin/package.json create mode 100644 examples/wasm-plugins/example-weather-provider/.npmignore create mode 100644 examples/wasm-plugins/example-weather-provider/README.md create mode 100644 examples/wasm-plugins/example-weather-provider/asconfig.json create mode 100644 examples/wasm-plugins/example-weather-provider/assembly/index.ts create mode 100644 examples/wasm-plugins/example-weather-provider/build/plugin.d.ts create mode 100644 examples/wasm-plugins/example-weather-provider/build/plugin.js create mode 100644 examples/wasm-plugins/example-weather-provider/package-lock.json create mode 100644 examples/wasm-plugins/example-weather-provider/package.json create mode 100644 packages/assemblyscript-plugin-sdk/.npmignore create mode 100644 packages/assemblyscript-plugin-sdk/LICENSE create mode 100644 packages/assemblyscript-plugin-sdk/README.md create mode 100644 packages/assemblyscript-plugin-sdk/asconfig.json create mode 100644 packages/assemblyscript-plugin-sdk/assembly/api.ts create mode 100644 packages/assemblyscript-plugin-sdk/assembly/index.ts create mode 100644 packages/assemblyscript-plugin-sdk/assembly/network.ts create mode 100644 packages/assemblyscript-plugin-sdk/assembly/plugin.ts create mode 100644 packages/assemblyscript-plugin-sdk/assembly/resources.ts create mode 100644 packages/assemblyscript-plugin-sdk/assembly/signalk.ts create mode 100644 packages/assemblyscript-plugin-sdk/build/plugin.d.ts create mode 100644 packages/assemblyscript-plugin-sdk/build/plugin.js create mode 100644 packages/assemblyscript-plugin-sdk/package.json create mode 100644 packages/server-api/wit/signalk.wit create mode 100644 src/interfaces/wasm.ts create mode 100644 src/pluginid.ts create mode 100644 src/wasm/bindings/binary-stream.ts create mode 100644 src/wasm/bindings/env-imports.ts create mode 100644 src/wasm/bindings/index.ts create mode 100644 src/wasm/bindings/radar-provider.ts create mode 100644 src/wasm/bindings/resource-provider.ts create mode 100644 src/wasm/bindings/signalk-api.ts create mode 100644 src/wasm/bindings/socket-manager.ts create mode 100644 src/wasm/bindings/weather-provider.ts create mode 100644 src/wasm/index.ts create mode 100644 src/wasm/loader/index.ts create mode 100644 src/wasm/loader/plugin-config.ts create mode 100644 src/wasm/loader/plugin-lifecycle.ts create mode 100644 src/wasm/loader/plugin-registry.ts create mode 100644 src/wasm/loader/plugin-routes.ts create mode 100644 src/wasm/loader/types.ts create mode 100644 src/wasm/loaders/component-loader.ts create mode 100644 src/wasm/loaders/index.ts create mode 100644 src/wasm/loaders/jco-loader.ts create mode 100644 src/wasm/loaders/standard-loader.ts create mode 100644 src/wasm/types.ts create mode 100644 src/wasm/utils/fetch-wrapper.ts create mode 100644 src/wasm/utils/format-detection.ts create mode 100644 src/wasm/utils/index.ts create mode 100644 src/wasm/wasm-runtime.ts create mode 100644 src/wasm/wasm-serverapi.ts create mode 100644 src/wasm/wasm-storage.ts create mode 100644 src/wasm/wasm-subscriptions.ts create mode 100644 test/wasm-plugin-test-config/package.json create mode 100644 test/wasm-plugins.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0e5c5775..d73064bf9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,6 +84,16 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish assemblyscript-plugin-sdk + run: | + LOCAL_VERSION=$(awk '/"version":/{gsub(/("|",)/,"",$2);print $2}' packages/assemblyscript-plugin-sdk/package.json) + if ! npm view @signalk/assemblyscript-plugin-sdk@$LOCAL_VERSION version &>/dev/null; then + cd packages/assemblyscript-plugin-sdk + npm publish --access public + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Set tag variable id: vars run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT diff --git a/.gitignore b/.gitignore index b5e93248c..725c39016 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,21 @@ test/server-test-config/ssl-key.pem test/server-test-config/plugin-config-data/ docs/built + +# WASM build artifacts +*.wasm + +# .net builds +jco-output/ +obj/ +bin/ +Debug/ +Release/ +*.ps1 +nul + +# wasmtime build artifacts +*.wasm + +# debug artefacts +*.sln \ No newline at end of file diff --git a/.npmignore b/.npmignore index 0d5c77b8b..a1836e7b3 100644 --- a/.npmignore +++ b/.npmignore @@ -44,6 +44,8 @@ compilation-stats.json .babelrc public/*.hot-update.js public/*.hot-update.json +**/stats.json +**/*.js.map plugin-config-data/ @@ -77,3 +79,20 @@ fly_io /docs/* !/docs/dist + + +# .net builds +jco-output/ +obj/ +bin/ +Debug/ +Release/ +*.ps1 +nul + +# wasmtime build artifacts +*.wasm + +# debug artefacts +*.sln + diff --git a/.prettierignore b/.prettierignore index 1ece09ce1..9beb9a7aa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,8 @@ public **/*.mbtiles **/*.pmtiles +# AssemblyScript files use decorators that prettier doesn't understand +examples/wasm-plugins/*/assembly +packages/assemblyscript-plugin-sdk/assembly +# AssemblyScript build outputs +packages/assemblyscript-plugin-sdk/build diff --git a/README.md b/README.md index 710332f80..7ef6adcf5 100644 --- a/README.md +++ b/README.md @@ -221,10 +221,10 @@ To enable debugging without going through the Admin UI, see the file `~/.signalk ## Development -The documents provide more details about developing Webapps or Plugings for Signal K Server, as well as working on the server itself: +The documents provide more details about developing Webapps or Plugins for Signal K Server, as well as working on the server itself: -- [Contributing to this repo](./contributing.md) -- [Server Plugins](docs/develop/plugins/README.md) +- [Contributing to this repo](docs/develop/contributing.md) +- [WASM Plugins](docs/develop/plugins/wasm/README.md) (Rust, AssemblyScript, Go) - [Webapps](docs/develop/webapps.md) - [Working with the Course API](docs/develop/rest-api/course_api.md) - [Working with the Resources API](docs/develop/rest-api/resources_api.md) diff --git a/docs/develop/plugins/README.md b/docs/develop/plugins/README.md index 4370e4926..80d823c8b 100644 --- a/docs/develop/plugins/README.md +++ b/docs/develop/plugins/README.md @@ -2,6 +2,7 @@ title: Plugins children: - ../webapps.md + - wasm/README.md - deltas.md - configuration.md - backpressure.md @@ -20,6 +21,17 @@ They are installed via the AppStore and configured via the Admin UI. Signal K server exposes an interface for plugins to use in order to interact with the full data model, emit delta messages and process requests. +## Plugin Types + +Signal K supports two types of plugins: + +- **JavaScript Plugins** - Traditional JavaScript/TypeScript plugins (documented below) +- **[WASM Plugins](./wasm/README.md)** - Plugins written in Rust, AssemblyScript, Go, or other WASM-compatible languages + +[WASM](https://en.wikipedia.org/wiki/WebAssembly) is short for WebAssembly. WASM is a runtime for executing portable code in near native speeds and in isolation. WASM plugins offer sandbox isolation, memory safety, and the ability to use languages other than JavaScript. See the [WASM Plugins documentation](./wasm/README.md) for details. + +## Node.js Plugin Capabilities + Plugins can: - Expose _[REST APIs](../rest-api/README.md)_ to provide consumers/clients a way to perform operations offered by your plugin. The APIs will be published under `http://{skserver}:3000/plugins/{pluginId}`. diff --git a/docs/develop/plugins/wasm/README.md b/docs/develop/plugins/wasm/README.md new file mode 100644 index 000000000..238bc645c --- /dev/null +++ b/docs/develop/plugins/wasm/README.md @@ -0,0 +1,155 @@ +--- +title: WASM Plugins +children: + - assemblyscript.md + - rust.md + - go.md + - dotnet.md + - http_endpoints.md + - deltas.md + - capabilities.md + - best_practices.md + - integration_guide.md +--- + +# WASM Plugin Development Guide + +## Overview + +This guide covers how to develop WASM/WASIX plugins for Signal K Server 3.0. WASM plugins run in a secure sandbox with isolated storage and capability-based permissions. + +## What Makes a WASM Plugin? + +A WASM plugin is an npm package that contains the WASM code for the plugin instead of the traditional JavaScript code. A WASM plugin is identified by the `signalk-wasm-plugin` keyword in package.json and the **`wasmManifest`** field in `package.json`: + +```json +{ + "name": "my-plugin-name", + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { ... } +} +``` + +**Key points:** + +- **`wasmManifest`** (required): Path to the compiled `.wasm` file. This field tells Signal K to load this as a WASM plugin instead of a Node.js plugin. +- **`wasmCapabilities`** (required): Declares what permissions the plugin needs (network, storage, etc.) +- **Package name** (flexible): Can be anything - `my-plugin`, `@myorg/my-plugin`, etc. There is **no requirement** to use `@signalk/` scope. +- **Keywords**: Include `signalk-wasm-plugin` for discovery (do **not** use `signalk-node-server-plugin` - that's for Node.js plugins only) + +## Language Options + +Signal K Server supports multiple languages for WASM plugin development: + +- **AssemblyScript** - TypeScript-like syntax, easiest for JS/TS developers, smallest binaries (3-10 KB) +- **Rust** - Best performance and tooling, medium binaries (50-200 KB) +- **Go/TinyGo** - Go via TinyGo compiler, medium binaries (50-150 KB) +- **C#/.NET** - **NOT WORKING** - .NET 10 with componentize-dotnet produces WASI Component Model (P2/P3) format. Currently incompatible with Node.js/jco runtime. See [Creating C#/.NET Plugins](./dotnet.md) for details. + +## Why WASM Plugins? + +### Benefits + +- **Security**: Sandboxed execution with no access to host system +- **Hot-reload**: Update plugins without server restart +- **Multi-language**: Write plugins in Rust, AssemblyScript, and more +- **Crash isolation**: Plugin crashes don't affect server +- **Performance**: Near-native performance with WASM +- **Self contained**: WASM plugins do not install any additional dependencies +- **Small binaries (compared to native options)**: 3-200 KB depending on language + +### Current Capabilities + +- **Delta Emission**: Send SignalK deltas to update vessel data +- **Status & Error Reporting**: Set plugin status and error messages +- **Configuration**: The same JSON schema-based configuration as JS plugins +- **Data Storage**: VFS-isolated file storage +- **HTTP Endpoints**: Register custom REST API endpoints +- **Static Files**: Serve web UI from `public/` directory +- **Network Access**: HTTP requests via as-fetch (AssemblyScript) +- **Resource Providers**: Serve SignalK resources +- **Weather Providers**: Integrate with Signal K Weather API +- **Radar Providers**: Integrate with Signal K Radar API + +## Choose Your Language + +### AssemblyScript - Recommended for JS/TS Developers + +**Best for:** + +- Quick prototypes +- Simple data processing +- Migrating existing Node.js plugins +- Developers familiar with TypeScript + +**Pros:** + +- TypeScript-like syntax +- Fast development +- Smallest binaries (3-10 KB) +- Familiar tooling (npm) + +**Cons:** + +- Smaller ecosystem than Rust +- Some TypeScript features unavailable +- Manual memory management + +**[Jump to AssemblyScript Guide](./assemblyscript.md)** + +### Rust - Recommended for Performance-Critical Plugins + +**Best for:** + +- Performance-critical plugins +- Complex algorithms +- Low-level operations +- Production plugins + +**Pros:** + +- Best performance +- Memory safety +- Rich ecosystem +- Strong typing + +**Cons:** + +- Steeper learning curve +- Longer compile times +- Larger binaries (50-200 KB) + +**[Jump to Rust Guide](./rust.md)** + +### Go/TinyGo - For Go Developers + +**Best for:** + +- Go developers wanting to write plugins +- Medium complexity plugins +- Resource providers with hybrid patterns + +**Pros:** + +- Familiar Go syntax +- Good standard library support +- Medium binaries (50-150 KB) +- Strong typing + +**Cons:** + +- Requires TinyGo (not standard Go) +- Some Go features unavailable +- Slower than Rust + +**[Jump to Go/TinyGo Guide](./go.md)** + +### C#/.NET - NOT CURRENTLY WORKING + +> **Status: Non-functional** - See [jco issue #1173](https://github.com/bytecodealliance/jco/issues/1173) for details and updates. + +componentize-dotnet produces WASI Component Model format which is currently incompatible with the Node.js/jco runtime used by Signal K. + +**Recommendation:** Use AssemblyScript or Rust instead. + +**[Jump to C#/.NET Guide](./dotnet.md)** (reference only) diff --git a/docs/develop/plugins/wasm/assemblyscript.md b/docs/develop/plugins/wasm/assemblyscript.md new file mode 100644 index 000000000..78d8215fe --- /dev/null +++ b/docs/develop/plugins/wasm/assemblyscript.md @@ -0,0 +1,614 @@ +--- +title: AssemblyScript Plugins +--- + +# Creating AssemblyScript Plugins + +AssemblyScript is the recommended language for developers familiar with TypeScript. It produces the smallest binaries (3-10 KB) and has the fastest development cycle. + +## Step 1: Install SDK + +```bash +npm install @signalk/assemblyscript-plugin-sdk +npm install --save-dev assemblyscript +``` + +## Step 2: Create Plugin File + +Create `assembly/index.ts`: + +```typescript +import { + Plugin, + Delta, + Update, + PathValue, + emit, + setStatus +} from '@signalk/assemblyscript-plugin-sdk/assembly' + +class MyPlugin extends Plugin { + name(): string { + return 'My AssemblyScript Plugin' + } + + schema(): string { + return `{ + "type": "object", + "properties": { + "updateRate": { + "type": "number", + "default": 1000 + } + } + }` + } + + start(config: string): i32 { + setStatus('Started') + + // Emit a test delta + const pathValue = new PathValue('test.value', '"hello"') + const update = new Update([pathValue]) + const delta = new Delta('vessels.self', [update]) + emit(delta) + + return 0 // Success + } + + stop(): i32 { + setStatus('Stopped') + return 0 + } +} + +// Export for Signal K +const plugin = new MyPlugin() +export function plugin_name(): string { + return plugin.name() +} +export function plugin_schema(): string { + return plugin.schema() +} +export function plugin_start(configPtr: usize, configLen: usize): i32 { + const configBytes = new Uint8Array(configLen) + for (let i = 0; i < configLen; i++) { + configBytes[i] = load(configPtr + i) + } + const configJson = String.UTF8.decode(configBytes.buffer) + return plugin.start(configJson) +} +export function plugin_stop(): i32 { + return plugin.stop() +} +``` + +**Note on Plugin IDs:** The plugin ID is automatically derived from your `package.json` name. For example: + +- `@signalk/example-weather-plugin` → `_signalk_example-weather-plugin` +- `my-simple-plugin` → `my-simple-plugin` + +This ensures unique plugin IDs (npm guarantees package name uniqueness) and eliminates discrepancies between package name and plugin ID. + +## Step 3: Configure Build + +Create `asconfig.json`: + +```json +{ + "targets": { + "release": { + "outFile": "plugin.wasm", + "optimize": true, + "shrinkLevel": 2, + "converge": true, + "noAssert": true, + "runtime": "incremental", + "exportRuntime": true + }, + "debug": { + "outFile": "build/plugin.debug.wasm", + "sourceMap": true, + "debug": true, + "runtime": "incremental", + "exportRuntime": true + } + }, + "options": { + "bindings": "esm" + } +} +``` + +**Important**: `exportRuntime: true` is **required** for the AssemblyScript loader to work. This exports runtime helper functions like `__newString` and `__getString` that the server uses for automatic string conversions. + +## Step 4: Build + +```bash +npx asc assembly/index.ts --target release +``` + +## Step 5: Create package.json + +```json +{ + "name": "my-wasm-plugin", + "version": "0.1.0", + "keywords": ["signalk-wasm-plugin"], + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { + "dataRead": true, + "dataWrite": true, + "storage": "vfs-only" + } +} +``` + +> **Important: What makes a WASM plugin?** +> +> The **`wasmManifest`** field is the key identifier that tells Signal K this is a WASM plugin (not a Node.js plugin). It must point to your compiled `.wasm` file. +> +> The package **name can be anything** - scoped (`@myorg/my-plugin`) or unscoped (`my-wasm-plugin`). Choose a name that makes sense for your plugin and avoids conflicts on npm. + +## Step 6: Test Install + +**Option 1: Symlink (Recommended for Development)** + +Symlinking your plugin directory allows you to make changes and rebuild without copying files: + +```bash +# From your Signal K node_modules directory +cd ~/.signalk/node_modules +ln -s /path/to/your/my-wasm-plugin my-wasm-plugin + +# Now any changes you make and rebuild will be picked up on server restart +``` + +**Option 2: Direct Copy** + +```bash +mkdir -p ~/.signalk/node_modules/my-wasm-plugin +cp plugin.wasm package.json ~/.signalk/node_modules/my-wasm-plugin/ + +# If your plugin has a public/ folder with web UI: +cp -r public ~/.signalk/node_modules/my-wasm-plugin/ +``` + +**Option 3: NPM Package Install** + +```bash +# If you've packaged with `npm pack` +npm install -g ./my-wasm-plugin-1.0.0.tgz + +# Or install from npm registry (if published) +npm install -g my-wasm-plugin +``` + +**Note**: Symlinking is the most efficient method for development - changes are picked up on server restart without copying files. Use npm install for production deployments or when distributing plugins. + +**Important**: If your plugin includes static files (like a web UI in the `public/` folder), make sure to copy that folder as well. Static files are automatically served at `/plugins/your-plugin-id/` when the plugin is loaded. + +## Step 7: Verify Plugin Configuration in Admin UI + +After installing your plugin, verify it appears in the Admin UI: + +1. **Navigate to Plugin Configuration**: Open the Admin UI at `http://your-server:3000/@signalk/server-admin-ui/` and go to **Server → Plugin Config** + +2. **Check Plugin List**: Your WASM plugin should appear in the list with: + - Plugin name (from `name()` export) + - Version (from `package.json`) + - Enable/Disable toggle + - Configuration form (based on `schema()` export) + +3. **Verify Configuration Persistence**: + - Configuration is saved to `~/.signalk/plugin-config-data/your-plugin-id.json` + - Changes are applied immediately (plugin restarts automatically) + - The file structure is: + ```json + { + "enabled": true, + "enableDebug": false, + "configuration": { + "updateRate": 1000 + } + } + ``` + +4. **Troubleshooting**: + - If plugin doesn't appear: Check `package.json` has the `signalk-wasm-plugin` keyword and `wasmManifest` field + - If configuration form is empty: Verify `schema()` export returns valid JSON Schema + - If settings don't persist: Check file permissions on `~/.signalk/plugin-config-data/` + +**Important**: The Admin UI shows all plugins (both Node.js and WASM) in a unified list. WASM plugins integrate seamlessly with the existing plugin configuration system. + +## API Reference + +### Base Classes + +#### `Plugin` + +Abstract base class for all plugins. + +**Methods to implement:** + +- `id(): string` - Unique plugin identifier +- `name(): string` - Human-readable name +- `schema(): string` - JSON schema for configuration +- `start(config: string): i32` - Initialize plugin +- `stop(): i32` - Clean shutdown + +### Signal K Types + +#### `Delta` + +Represents a Signal K delta message. + +```typescript +const delta = new Delta('vessels.self', [update]) +``` + +#### `Update` + +Represents an update within a delta. The server automatically adds `$source` and `timestamp`. + +```typescript +const update = new Update([pathValue]) +``` + +#### `PathValue` + +Represents a path-value pair. + +```typescript +const pathValue = new PathValue('navigation.position', positionJson) +``` + +#### `Position` + +GPS position with latitude/longitude. + +```typescript +const pos = new Position(60.1, 24.9) +const posJson = pos.toJSON() +``` + +#### `Notification` + +Signal K notification. + +```typescript +const notif = new Notification(NotificationState.normal, 'Hello!') +const notifJson = notif.toJSON() +``` + +### API Functions + +#### `emit(delta: Delta): void` + +Emit a delta message to Signal K server. + +```typescript +emit(delta) +``` + +**Requires capability:** `dataWrite: true` + +#### `setStatus(message: string): void` + +Set plugin status (shown in admin UI). + +```typescript +setStatus('Running normally') +``` + +#### `setError(message: string): void` + +Report an error (shown in admin UI). + +```typescript +setError('Sensor connection failed') +``` + +#### `debug(message: string): void` + +Log debug message to server logs. + +```typescript +debug('Processing data: ' + value.toString()) +``` + +#### `getSelfPath(path: string): string | null` + +Read data from vessel.self. + +```typescript +const speedJson = getSelfPath('navigation.speedOverGround') +if (speedJson !== null) { + const speed = parseFloat(speedJson) +} +``` + +**Requires capability:** `dataRead: true` + +#### `getPath(path: string): string | null` + +Read data from any context. + +```typescript +const posJson = getPath('vessels.self.navigation.position') +``` + +**Requires capability:** `dataRead: true` + +#### `readConfig(): string` + +Read plugin configuration. + +```typescript +const configJson = readConfig() +``` + +#### `saveConfig(configJson: string): i32` + +Save plugin configuration. + +```typescript +const result = saveConfig(JSON.stringify(config)) +if (result !== 0) { + setError('Failed to save config') +} +``` + +### Helper Functions + +```typescript +import { + createSimpleDelta, + getCurrentTimestamp +} from '@signalk/assemblyscript-plugin-sdk' + +// Quick delta creation +const delta = createSimpleDelta('my-plugin', 'test.value', '"hello"') +emit(delta) +``` + +### JSON Parsing + +The SDK includes [assemblyscript-json](https://github.com/near/assemblyscript-json) for parsing JSON data. This is useful when working with configuration, API responses, or resource provider requests. + +```typescript +import { JSON } from '@signalk/assemblyscript-plugin-sdk/assembly' + +// Parse a JSON string +const jsonStr = '{"name": "My Boat", "speed": 5.2}' +const parsed = JSON.parse(jsonStr) + +if (parsed.isObj) { + const obj = parsed as JSON.Obj + + // Get string values + const nameValue = obj.getString('name') + if (nameValue !== null) { + const name = nameValue.valueOf() // "My Boat" + } + + // Get number values + const speedValue = obj.getNum('speed') + if (speedValue !== null) { + const speed = speedValue.valueOf() // 5.2 (as f64) + } +} +``` + +**Available methods on `JSON.Obj`:** + +- `getString(key)` - Returns `JSON.Str | null` +- `getNum(key)` - Returns `JSON.Num | null` +- `getBool(key)` - Returns `JSON.Bool | null` +- `getObj(key)` - Returns `JSON.Obj | null` +- `getArr(key)` - Returns `JSON.Arr | null` +- `getValue(key)` - Returns `JSON.Value | null` + +**Note:** Plugins using resource providers or parsing complex JSON should add `assemblyscript-json` to their dependencies: + +```bash +npm install assemblyscript-json +``` + +### JSON Value Encoding + +Values must be JSON-encoded strings: + +```typescript +// Numbers +const pathValue = new PathValue('temperature', '25.5') + +// Strings (note the quotes) +const pathValue = new PathValue('name', '"My Boat"') + +// Objects +const pathValue = new PathValue( + 'position', + '{"latitude":60.1,"longitude":24.9}' +) + +// Use helper classes +const pos = new Position(60.1, 24.9) +const pathValue = new PathValue('position', pos.toJSON()) +``` + +## Resource Providers + +WASM plugins can register as **resource providers** to serve data via the Signal K REST API. + +### Setup + +1. Add capability to `package.json`: + +```json +{ + "wasmCapabilities": { + "resourceProvider": true + } +} +``` + +2. Register in your plugin's `start()`: + +```typescript +import { + registerResourceProvider, + ResourceGetRequest +} from '@signalk/assemblyscript-plugin-sdk/assembly/resources' + +start(config: string): i32 { + if (registerResourceProvider('weather')) { + debug('Registered as weather resource provider') + } + return 0 +} +``` + +3. Export handler functions: + +```typescript +// List all resources - GET /signalk/v2/api/resources/weather +export function resources_list_resources(queryJson: string): string { + return '{"current":' + cachedData.toJSON() + '}' +} + +// Get specific resource - GET /signalk/v2/api/resources/weather/{id} +export function resources_get_resource(requestJson: string): string { + const req = ResourceGetRequest.parse(requestJson) + if (req.id === 'current') { + return cachedData.toJSON() + } + return '{"error":"Not found"}' +} +``` + +### API Access + +Once registered, your resources are available at: + +```bash +curl http://localhost:3000/signalk/v2/api/resources/weather +curl http://localhost:3000/signalk/v2/api/resources/weather/current +``` + +## Network Requests with Asyncify + +AssemblyScript plugins can make HTTP requests using the `as-fetch` library with Asyncify support. + +### Setup + +1. Add dependencies: + +```bash +npm install as-fetch @signalk/assemblyscript-plugin-sdk +``` + +2. Enable the Asyncify transform in `asconfig.json`: + +```json +{ + "options": { + "bindings": "esm", + "exportRuntime": true, + "transform": ["as-fetch/transform"] + } +} +``` + +3. Declare network capability in `package.json`: + +```json +{ + "wasmCapabilities": { + "network": true + } +} +``` + +### Making Requests + +```typescript +import { fetchSync } from 'as-fetch/sync' + +const response = fetchSync('https://api.example.com/data') + +if (response && response.status === 200) { + const data = response.text() + // Process data... +} +``` + +### How Asyncify Works + +Asyncify enables synchronous-style async code in WASM: + +1. WASM execution pauses when `fetchSync()` is called +2. HTTP request happens in JavaScript +3. When response arrives, WASM execution resumes +4. Your code continues with the response + +The Signal K runtime handles all state transitions automatically. + +### Troubleshooting Network Requests + +**fetchSync hangs or doesn't work:** + +- Ensure `"transform": ["as-fetch/transform"]` is in `asconfig.json` +- Use correct import: `import { fetchSync } from 'as-fetch/sync'` +- Verify `"network": true` in `wasmCapabilities` + +**Request fails:** + +- Check Node.js version >= 18 (required for native fetch) +- Verify the URL is accessible +- Check API keys/authentication + +See the [example-weather-plugin](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-weather-plugin) for a complete implementation. + +## AssemblyScript Limitations + +AssemblyScript is a **strict subset** of TypeScript. Notable differences: + +- No `any` type +- No union types (use tagged enums) +- No dynamic arrays (use fixed-size or manual memory) +- No standard library (console, setTimeout, etc.) +- Manual memory management + +See [AssemblyScript documentation](https://www.assemblyscript.org/) for details. + +## Troubleshooting + +### Plugin doesn't load + +Check that: + +- `wasmManifest` points to correct file +- `signalk-wasm-plugin` keyword is present +- WASM binary is valid: `file plugin.wasm` + +### Compilation errors + +Common issues: + +- Using disallowed TypeScript features +- Missing type annotations +- Incorrect memory operations + +### Runtime errors + +Check server logs: + +```bash +DEBUG=signalk:wasm:* npm start +``` + +## Additional Resources + +- [AssemblyScript Documentation](https://www.assemblyscript.org/) +- [Example Plugins](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins) diff --git a/docs/develop/plugins/wasm/best_practices.md b/docs/develop/plugins/wasm/best_practices.md new file mode 100644 index 000000000..58255396d --- /dev/null +++ b/docs/develop/plugins/wasm/best_practices.md @@ -0,0 +1,234 @@ +--- +title: Best Practices for WASM Plugins +--- + +# Best Practices for WASM Plugins + +## Hot Reload + +WASM plugins support hot-reload without server restart: + +### Manual Reload + +1. Build new WASM binary: `cargo build --target wasm32-wasip1 --release` +2. Copy to plugin directory: `cp target/wasm32-wasip1/release/*.wasm ~/.signalk/...` +3. In Admin UI: **Server** → **Plugin Config** → Click **Reload** button + +### Reload Behavior + +During reload: + +- `stop()` is called on old instance +- Subscriptions are preserved +- Deltas are buffered (not lost) +- New instance is loaded +- `start()` is called with saved config +- Buffered deltas are replayed + +## Error Handling + +### Crash Recovery + +If a WASM plugin crashes: + +1. **First crash**: Automatic restart after 1 second +2. **Second crash**: Restart after 2 seconds +3. **Third crash**: Restart after 4 seconds +4. **After 3 crashes**: Plugin disabled, admin notification + +### Error Reporting + +Report errors to admin UI: + +```rust +fn handle_error(err: &str) { + sk_set_error(&format!("Error: {}", err)); +} +``` + +## Optimization + +### 1. Minimize Binary Size + +```toml +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Enable link-time optimization +strip = true # Strip debug symbols +``` + +Use `wasm-opt` for further optimization: + +```bash +wasm-opt -Oz plugin.wasm -o plugin.wasm +``` + +### 2. Handle Errors Gracefully + +```rust +fn start(config_ptr: *const u8, config_len: usize) -> i32 { + match initialize_plugin(config_ptr, config_len) { + Ok(_) => { + sk_set_status("Started"); + 0 // Success + } + Err(e) => { + sk_set_error(&format!("Failed to start: {}", e)); + 1 // Error + } + } +} +``` + +### 3. Use Efficient JSON Parsing + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +struct Config { + #[serde(default)] + enabled: bool, +} + +fn parse_config(json: &str) -> Result { + serde_json::from_str(json) +} +``` + +### 4. Limit Memory Usage + +- Avoid large allocations +- Clear buffers after use +- Use streaming for large data + +### WASM Memory Limitations + +WASM plugins running in Node.js have **~64KB buffer limitations** for stdin/stdout operations. This is a fundamental limitation of the Node.js WASI implementation, not a Signal K restriction. + +**Impact:** + +- Small JSON responses (< 64KB): Work fine in pure WASM +- Medium data (64KB - 1MB): May freeze or fail +- Large data (> 1MB): Will fail or freeze the server + +**Hybrid Architecture Pattern** + +For plugins that need to handle large data volumes (logs, file streaming, large JSON responses), use a **hybrid approach**: + +- **WASM Plugin**: Registers HTTP endpoints and provides configuration UI +- **Node.js Handler**: Server intercepts specific endpoints and handles I/O directly in Node.js +- **Result**: Can handle unlimited data without memory constraints + +Use this pattern when your plugin needs to: + +- Return large JSON responses (> 64KB) +- Process large file uploads +- Handle streaming data + +### 5. Provide Good UX + +- Clear status messages +- Descriptive error messages +- Comprehensive JSON schema for configuration + +## Debugging + +### Logging + +```rust +fn debug_log(message: &str) { + unsafe { + sk_debug(message.as_ptr(), message.len()); + } +} +``` + +### Testing Locally + +1. Build with debug symbols: `cargo build --target wasm32-wasip1` +2. Use `wasmtime` for local testing: + +```bash +wasmtime --dir /tmp::/ plugin.wasm +``` + +### Enable Server Debug Logging + +```bash +# Linux/macOS +DEBUG=signalk:wasm:* signalk-server +``` + +### Common Issues + +**Issue**: Plugin doesn't load +**Solution**: Check `wasmManifest` path in package.json + +**Issue**: Capability errors +**Solution**: Ensure required capabilities declared in package.json + +**Issue**: Crashes on start +**Solution**: Check server logs for error details + +## Migration from Node.js + +### 1. Assess Compatibility + +Check if your plugin: + +- ✅ Processes deltas +- ✅ Reads/writes configuration +- ✅ Uses data model APIs +- ✅ Registers REST endpoints +- ❌ Uses serial ports (planned but not there yet) +- ✅ Makes HTTP requests (via as-fetch in AssemblyScript) +- ✅ Uses UDP/TCP sockets (rawSockets capability) + +### 2. Port Logic to Rust + +Convert TypeScript/JavaScript logic to Rust: + +**Before (Node.js):** + +```javascript +plugin.start = function (config) { + app.handleMessage('my-plugin', { + updates: [{ values: [{ path: 'foo', value: 'bar' }] }] + }) +} +``` + +**After (WASM/Rust):** + +```rust +fn start(config_ptr: *const u8, config_len: usize) -> i32 { + let delta = json!({ + "updates": [{ "values": [{ "path": "foo", "value": "bar" }] }] + }); + sk_emit_delta(&delta.to_string()); + 0 +} +``` + +### 3. Migrate Data + +Use migration helper to copy existing data to VFS: + +```rust +fn first_run_migration() { + // Server provides migration API + // Copies files from ~/.signalk/plugin-config-data/{id}/ + // to ~/.signalk/plugin-config-data/{id}/vfs/data/ +} +``` + +## Example Plugins + +The following example plugins are available in the repository: + +- [example-hello-assemblyscript](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-hello-assemblyscript) - Minimal AssemblyScript plugin that emits a delta on start +- [example-anchor-watch-rust](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-anchor-watch-rust) - Anchor watch plugin in Rust +- [example-routes-waypoints](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-routes-waypoints) - Resource provider for routes and waypoints +- [example-weather-provider](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-weather-provider) - Weather API provider implementation +- [example-weather-plugin](https://github.com/SignalK/signalk-server/tree/master/examples/wasm-plugins/example-weather-plugin) - Weather data plugin diff --git a/docs/develop/plugins/wasm/capabilities.md b/docs/develop/plugins/wasm/capabilities.md new file mode 100644 index 000000000..539238bee --- /dev/null +++ b/docs/develop/plugins/wasm/capabilities.md @@ -0,0 +1,414 @@ +--- +title: Plugin Capabilities +--- + +# Plugin Capabilities + +## Capability Types + +Declare required capabilities in `package.json`: + +| Capability | Description | Status | +| --------------- | ---------------------------------------- | ------------------------------- | +| `dataRead` | Read Signal K data model | Supported | +| `dataWrite` | Emit delta messages | Supported | +| `storage` | Write to VFS (`vfs-only`) | Supported | +| `httpEndpoints` | Register custom HTTP endpoints | Supported | +| `staticFiles` | Serve HTML/CSS/JS from `public/` folder | Supported | +| `network` | HTTP requests (via as-fetch) | Supported (AssemblyScript only) | +| `putHandlers` | Register PUT handlers for vessel control | Supported | +| `rawSockets` | UDP socket access for radar, NMEA, etc. | Supported | +| `serialPorts` | Serial port access | Planned | + +## Network API (AssemblyScript) + +AssemblyScript plugins can make HTTP requests using the `as-fetch` library integrated into the SDK. + +**Requirements:** + +- Plugin must declare `"network": true` in manifest +- Server must be running Node.js 18+ (for native fetch support) +- Import network functions from SDK +- Must add `"transform": ["as-fetch/transform"]` to `asconfig.json` options +- Must set `"exportRuntime": true` in `asconfig.json` options + +**Example: HTTP GET Request** + +```typescript +import { + httpGet, + hasNetworkCapability +} from '@signalk/assemblyscript-plugin-sdk/assembly/network' +import { debug, setError } from '@signalk/assemblyscript-plugin-sdk/assembly' + +class MyPlugin extends Plugin { + start(config: string): i32 { + // Always check capability first + if (!hasNetworkCapability()) { + setError('Network capability not granted') + return 1 + } + + // Make HTTP GET request + const response = httpGet('https://api.example.com/data') + if (response === null) { + setError('HTTP request failed') + return 1 + } + + debug('Received: ' + response) + return 0 + } +} +``` + +**Available Network Functions:** + +```typescript +// Check if network capability is granted +hasNetworkCapability(): boolean + +// HTTP GET request - returns response body or null on error +httpGet(url: string): string | null + +// HTTP POST request - returns status code or -1 on error +httpPost(url: string, body: string): i32 + +// HTTP POST with response - returns response body or null +httpPostWithResponse(url: string, body: string): string | null + +// HTTP PUT request - returns status code or -1 on error +httpPut(url: string, body: string): i32 + +// HTTP DELETE request - returns status code or -1 on error +httpDelete(url: string): i32 + +// Advanced HTTP request with full control +httpRequest( + url: string, + method: string, + body: string | null, + contentType: string | null +): HttpResponse | null +``` + +**Build Configuration (asconfig.json):** + +For plugins using network capability: + +```json +{ + "targets": { + "release": { + "outFile": "build/plugin.wasm", + "optimize": true, + "shrinkLevel": 2, + "runtime": "stub" + } + }, + "options": { + "bindings": "esm", + "exportRuntime": true, + "transform": ["as-fetch/transform"] + } +} +``` + +**Manifest Configuration:** + +```json +{ + "name": "my-plugin", + "wasmCapabilities": { + "network": true + }, + "dependencies": { + "@signalk/assemblyscript-plugin-sdk": "^0.2.0", + "as-fetch": "^2.1.4" + } +} +``` + +## Raw Sockets API (UDP) + +The `rawSockets` capability enables direct UDP socket access for plugins that need to communicate with devices like: + +- Marine radars (Navico, Raymarine, Furuno, Garmin) +- NMEA 0183 over UDP +- AIS receivers +- Other marine electronics using UDP multicast + +**Requirements:** + +- Plugin must declare `"rawSockets": true` in manifest +- Sockets are non-blocking (poll-based receive) +- Automatic cleanup when plugin stops + +**Manifest Configuration:** + +```json +{ + "name": "my-radar-plugin", + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { + "rawSockets": true, + "dataWrite": true + } +} +``` + +**FFI Functions Available:** + +| Function | Signature | Description | +| ------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------- | +| `sk_udp_create` | `(type: i32) -> i32` | Create socket (0=udp4, 1=udp6). Returns socket_id or -1 | +| `sk_udp_bind` | `(socket_id, port) -> i32` | Bind to port (0=any). Returns 0 or -1 | +| `sk_udp_join_multicast` | `(socket_id, addr_ptr, addr_len, iface_ptr, iface_len) -> i32` | Join multicast group | +| `sk_udp_leave_multicast` | `(socket_id, addr_ptr, addr_len, iface_ptr, iface_len) -> i32` | Leave multicast group | +| `sk_udp_set_multicast_ttl` | `(socket_id, ttl) -> i32` | Set multicast TTL | +| `sk_udp_set_multicast_loopback` | `(socket_id, enabled) -> i32` | Enable/disable loopback | +| `sk_udp_set_broadcast` | `(socket_id, enabled) -> i32` | Enable/disable broadcast | +| `sk_udp_send` | `(socket_id, addr_ptr, addr_len, port, data_ptr, data_len) -> i32` | Send datagram | +| `sk_udp_recv` | `(socket_id, buf_ptr, buf_max_len, addr_out_ptr, port_out_ptr) -> i32` | Receive datagram (non-blocking) | +| `sk_udp_pending` | `(socket_id) -> i32` | Get number of buffered datagrams | +| `sk_udp_close` | `(socket_id) -> void` | Close socket | + +**Rust Example:** + +```rust +#[link(wasm_import_module = "env")] +extern "C" { + fn sk_udp_create(socket_type: i32) -> i32; + fn sk_udp_bind(socket_id: i32, port: u16) -> i32; + fn sk_udp_join_multicast( + socket_id: i32, + addr_ptr: *const u8, addr_len: usize, + iface_ptr: *const u8, iface_len: usize + ) -> i32; + fn sk_udp_recv( + socket_id: i32, + buf_ptr: *mut u8, buf_max_len: usize, + addr_out_ptr: *mut u8, port_out_ptr: *mut u16 + ) -> i32; + fn sk_udp_close(socket_id: i32); +} + +// Example: Radar discovery +fn start_radar_locator() -> i32 { + // Create UDP socket + let socket_id = unsafe { sk_udp_create(0) }; // udp4 + if socket_id < 0 { + return -1; + } + + // Bind to radar discovery port + if unsafe { sk_udp_bind(socket_id, 6878) } < 0 { + return -1; + } + + // Join radar multicast group + let group = "239.254.2.0"; + let iface = ""; + if unsafe { sk_udp_join_multicast(socket_id, group.as_ptr(), group.len(), iface.as_ptr(), iface.len()) } < 0 { + return -1; + } + + socket_id +} +``` + +**Important Notes:** + +- Receive is non-blocking - returns 0 if no data available +- Incoming datagrams are buffered (max 1000 per socket) +- Oldest datagrams are dropped if buffer is full +- All sockets are automatically closed when plugin stops +- Use `sk_udp_pending()` to check if data is available before calling `sk_udp_recv()` + +## Raw Sockets API (TCP) + +The `rawSockets` capability also enables TCP socket access for plugins that need persistent connections to devices: + +- Marine radars with TCP control (Furuno, Garmin) +- Devices requiring handshake/login protocols +- Any marine electronics using TCP + +TCP sockets support both **line-buffered mode** (for text protocols with `\r\n` terminators) and **raw mode** (for binary protocols). + +**FFI Functions Available:** + +| Function | Signature | Description | +| --------------------------- | ---------------------------------------------- | ------------------------------------------------------------ | +| `sk_tcp_create` | `() -> i32` | Create TCP socket. Returns socket_id or -1 | +| `sk_tcp_connect` | `(socket_id, addr_ptr, addr_len, port) -> i32` | Initiate connection (non-blocking). Returns 0 or -1 | +| `sk_tcp_connected` | `(socket_id) -> i32` | Check connection status. Returns 1 if connected, 0 otherwise | +| `sk_tcp_set_line_buffering` | `(socket_id, enabled) -> i32` | Set buffering mode (1=line, 0=raw). Default: line | +| `sk_tcp_send` | `(socket_id, data_ptr, data_len) -> i32` | Send data. Returns bytes sent or -1 | +| `sk_tcp_recv_line` | `(socket_id, buf_ptr, buf_max_len) -> i32` | Receive complete line (line mode). Returns len or 0 | +| `sk_tcp_recv_raw` | `(socket_id, buf_ptr, buf_max_len) -> i32` | Receive raw data (raw mode). Returns len or 0 | +| `sk_tcp_pending` | `(socket_id) -> i32` | Get buffered item count | +| `sk_tcp_close` | `(socket_id) -> void` | Close socket | + +**Rust Example:** + +```rust +#[link(wasm_import_module = "env")] +extern "C" { + fn sk_tcp_create() -> i32; + fn sk_tcp_connect(socket_id: i32, addr_ptr: *const u8, addr_len: usize, port: u16) -> i32; + fn sk_tcp_connected(socket_id: i32) -> i32; + fn sk_tcp_set_line_buffering(socket_id: i32, enabled: i32) -> i32; + fn sk_tcp_send(socket_id: i32, data_ptr: *const u8, data_len: usize) -> i32; + fn sk_tcp_recv_line(socket_id: i32, buf_ptr: *mut u8, buf_max_len: usize) -> i32; + fn sk_tcp_recv_raw(socket_id: i32, buf_ptr: *mut u8, buf_max_len: usize) -> i32; + fn sk_tcp_pending(socket_id: i32) -> i32; + fn sk_tcp_close(socket_id: i32); +} + +// Example: Furuno radar control connection +fn connect_furuno_radar(ip: &str, port: u16) -> i32 { + // Create TCP socket + let socket_id = unsafe { sk_tcp_create() }; + if socket_id < 0 { + return -1; + } + + // Initiate connection (non-blocking) + if unsafe { sk_tcp_connect(socket_id, ip.as_ptr(), ip.len(), port) } < 0 { + return -1; + } + + socket_id +} + +fn poll_connection(socket_id: i32) { + // Check if connected + if unsafe { sk_tcp_connected(socket_id) } != 1 { + return; // Still connecting + } + + // Send command with \r\n terminator + let cmd = "$S69,2,0,0,60,300,0\r\n"; + unsafe { sk_tcp_send(socket_id, cmd.as_ptr(), cmd.len()) }; + + // Receive response line + let mut buf = [0u8; 256]; + let len = unsafe { sk_tcp_recv_line(socket_id, buf.as_mut_ptr(), buf.len()) }; + if len > 0 { + // Process response + } +} +``` + +**Important Notes:** + +- Connection is non-blocking - poll `sk_tcp_connected()` until connected +- Line-buffered mode (default) splits incoming data on `\r\n` or `\n` +- Raw mode returns data as it arrives (for binary protocols) +- Use `sk_tcp_pending()` to check if data is available +- All sockets are automatically closed when plugin stops + +## PUT Handlers API + +WASM plugins can register PUT handlers to respond to PUT requests from clients, enabling vessel control and configuration management. + +**Requirements:** + +- Plugin must declare `"putHandlers": true` in manifest +- Import PUT handler functions from FFI +- Register handlers during `plugin_start()` +- Export handler functions with correct naming convention + +**Manifest Configuration:** + +```json +{ + "name": "my-plugin", + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { + "putHandlers": true + } +} +``` + +**Handler Naming Convention:** + +**Format:** `handle_put_{context}_{path}` + +- Replace all dots (`.`) with underscores (`_`) +- Convert to lowercase (recommended) + +**Examples:** + +| Context | Path | Handler Function Name | +| -------------- | --------------------------------------- | --------------------------------------------------------------- | +| `vessels.self` | `navigation.anchor.position` | `handle_put_vessels_self_navigation_anchor_position` | +| `vessels.self` | `steering.autopilot.target.headingTrue` | `handle_put_vessels_self_steering_autopilot_target_headingTrue` | + +**Response Format:** + +```json +{ + "state": "COMPLETED", + "statusCode": 200, + "message": "Operation successful" +} +``` + +- `state` - Request state: `COMPLETED` or `PENDING` +- `statusCode` - HTTP status code (200, 400, 403, 500, 501) +- `message` - Human-readable message (optional) + +## Storage API + +Plugins have access to isolated virtual filesystem: + +```rust +use std::fs; + +fn save_state() { + // Plugin sees "/" as its VFS root + fs::write("/data/state.json", state_json).unwrap(); +} + +fn load_state() -> String { + fs::read_to_string("/data/state.json").unwrap_or_default() +} +``` + +**VFS Structure:** + +``` +/ (VFS root) +├── data/ # Persistent storage +├── config/ # Plugin-managed config +└── tmp/ # Temporary files +``` + +## Delta Emission + +Emit delta messages to update Signal K data: + +```rust +fn emit_position_delta() { + let delta = r#"{ + "context": "vessels.self", + "updates": [{ + "source": { + "label": "example-wasm", + "type": "plugin" + }, + "timestamp": "2025-12-01T10:00:00.000Z", + "values": [{ + "path": "navigation.position", + "value": { + "latitude": 60.1, + "longitude": 24.9 + } + }] + }] + }"#; + + handle_message(&delta); +} +``` diff --git a/docs/develop/plugins/wasm/deltas.md b/docs/develop/plugins/wasm/deltas.md new file mode 100644 index 000000000..02ed9eb7b --- /dev/null +++ b/docs/develop/plugins/wasm/deltas.md @@ -0,0 +1,193 @@ +--- +title: Deltas +--- + +# Working with Signal K Deltas + +WASM plugins can both **emit** and **receive** Signal K deltas. This page covers both directions. + +## Emitting Deltas + +Use the `emit()` function to send delta messages to the Signal K server: + +```typescript +import { + emit, + createSimpleDelta, + SK_VERSION_V1, + SK_VERSION_V2 +} from '@signalk/assemblyscript-plugin-sdk/assembly' + +// Emit a v1 delta (default - for regular navigation data) +const tempDelta = createSimpleDelta('environment.outside.temperature', '288.15') +emit(tempDelta) + +// Emit a v2 delta (for Course API and v2-specific paths) +const courseDelta = createSimpleDelta( + 'navigation.course.nextPoint', + positionJson +) +emit(courseDelta, SK_VERSION_V2) +``` + +**Note:** Plugins should NOT include `source` or `timestamp` in emitted deltas. The server automatically: + +- Sets `$source` to the plugin ID +- Fills in `timestamp` with the current time + +### Signal K v1 vs v2 Deltas + +The `emit()` function accepts an optional second parameter to specify the Signal K version: + +| Version | Constant | Use Case | +| ------------ | --------------- | ------------------------------------------------------------------------------------------ | +| v1 (default) | `SK_VERSION_V1` | Regular navigation data: `navigation.*`, `environment.*`, `electrical.*`, etc. | +| v2 | `SK_VERSION_V2` | Course API paths and v2-specific data that should not be mixed into the v1 full data model | + +**Why does this matter?** + +- **v1 deltas** update the full Signal K data model and are available via the REST API and WebSocket subscriptions +- **v2 deltas** are emitted as events for v2 API subscribers without mixing into the v1 data model + +Most plugins should use v1 (the default). Only use v2 when emitting Course API data or other v2-specific paths. + +This mirrors the TypeScript plugin API where `handleMessage()` accepts an optional `skVersion` parameter. + +--- + +## Receiving Deltas + +WASM plugins can subscribe to receive Signal K deltas, enabling them to react to navigation data changes, course updates, sensor readings, and other vessel data in real-time. + +## Implementing a Delta Handler + +Export a `delta_handler()` function to receive deltas: + +```typescript +// assembly/index.ts + +// Plugin state +let vesselLat: f64 = 0.0 +let vesselLon: f64 = 0.0 +let hasPosition: bool = false + +export function delta_handler(deltaJson: string): void { + // Check for position updates + if (deltaJson.indexOf('"path":"navigation.position"') >= 0) { + const lat = parseFloat64FromJson(deltaJson, 'latitude') + const lon = parseFloat64FromJson(deltaJson, 'longitude') + + if (lat !== 0.0 || lon !== 0.0) { + vesselLat = lat + vesselLon = lon + hasPosition = true + debug('Position updated: ' + lat.toString() + ', ' + lon.toString()) + } + } + + // Check for course nextPoint + if (deltaJson.indexOf('"path":"navigation.course.nextPoint"') >= 0) { + // Extract destination coordinates and perform calculations + // ... + } + + // Check for speedOverGround + if (deltaJson.indexOf('"navigation.speedOverGround"') >= 0) { + const speed = parseFloat64FromJson(deltaJson, 'value') + // Process speed data + } +} + +// Helper function to parse float from JSON +function parseFloat64FromJson(json: string, key: string): f64 { + const searchKey = '"' + key + '":' + const match = json.indexOf(searchKey) + if (match < 0) return 0.0 + + let start = match + searchKey.length + while ( + start < json.length && + (json.charCodeAt(start) == 32 || json.charCodeAt(start) == 9) + ) { + start++ + } + + let end = start + while (end < json.length) { + const c = json.charCodeAt(end) + if (c == 44 || c == 125 || c == 93) break // comma, }, ] + end++ + } + + const numStr = json.substring(start, end).trim() + return parseFloat(numStr) +} +``` + +## Received Delta JSON Format + +Deltas received by `delta_handler()` include `$source` and `timestamp` (added by the server): + +```json +{ + "context": "vessels.self", + "updates": [ + { + "$source": "n2k-on-ve.can-socket.43", + "timestamp": "2024-01-15T12:30:00.000Z", + "values": [ + { + "path": "navigation.position", + "value": { "latitude": -17.68, "longitude": 177.39 } + }, + { "path": "navigation.speedOverGround", "value": 5.2 } + ] + } + ] +} +``` + +## Common Use Cases + +1. **Course Calculations** - React to `navigation.course.nextPoint` and `navigation.position` to calculate bearing, distance, XTE +2. **Anchor Watch** - Monitor `navigation.position` and compare to anchor position +3. **Speed Alerts** - Watch `navigation.speedOverGround` for threshold breaches +4. **Environment Monitoring** - Track `environment.wind.*`, `environment.water.temperature`, etc. + +## Detecting Cleared Values + +When values are cleared (e.g., destination removed), the server sends `null` values: + +```typescript +export function delta_handler(deltaJson: string): void { + if (deltaJson.indexOf('"path":"navigation.course.nextPoint"') >= 0) { + // Try to extract position first + const lat = parseFloat64FromJson(deltaJson, 'latitude') + const lon = parseFloat64FromJson(deltaJson, 'longitude') + + if (lat !== 0.0 || lon !== 0.0) { + // Valid position - update state + nextPointLat = lat + nextPointLon = lon + hasDestination = true + } else { + // Check if this is a null/clear operation + const pathIdx = deltaJson.indexOf('"path":"navigation.course.nextPoint"') + const checkRange = deltaJson.substring( + pathIdx, + Math.min(pathIdx + 100, deltaJson.length) as i32 + ) + if (checkRange.indexOf('"value":null') >= 0) { + hasDestination = false + debug('Destination cleared') + } + } + } +} +``` + +## Performance Considerations + +- **Filter Early** - Check for relevant paths before parsing to minimize processing +- **State Caching** - Store parsed values in global variables rather than re-parsing +- **Debouncing** - High-frequency data (GPS at 10Hz) may benefit from debouncing calculations diff --git a/docs/develop/plugins/wasm/dotnet.md b/docs/develop/plugins/wasm/dotnet.md new file mode 100644 index 000000000..dc29835cd --- /dev/null +++ b/docs/develop/plugins/wasm/dotnet.md @@ -0,0 +1,493 @@ +--- +title: C#/.NET Plugins +--- + +# Creating C#/.NET Plugins + +> **NOT WORKING**: .NET WASM plugins cannot run in Signal K's Node.js/jco environment. +> componentize-dotnet only supports Wasmtime and WAMR runtimes. This section is preserved +> for future reference when tooling improves. +> +> **Use AssemblyScript or Rust instead for working WASM plugins.** + +## Why C#/.NET Doesn't Work (Dec 2024) + +The .NET WASM toolchain (`componentize-dotnet`) produces WASI Component Model output that +requires native Wasmtime or WAMR to execute. When transpiled via jco to JavaScript: + +1. The WASM module loads successfully +2. The `$init` promise resolves +3. All functions appear to be exported +4. **Calling any function crashes** with `RuntimeError: null function or function signature mismatch` + +This happens because .NET NativeAOT uses indirect call tables that are initialized by +`_initialize()`. In Wasmtime, this works correctly. In V8 (via jco), the table entries +remain null, causing every function call to fail. + +**Workarounds attempted:** + +- Manual `_initialize()` call - no effect +- `InitializeModules()` call - crashes (already called by `_initialize`) +- Removing `[ThreadStatic]` attribute - fixed build but not runtime +- Various jco flags (`--tla-compat`, `--instantiation sync`) - no effect + +**Conclusion:** Wait for better tooling. Both componentize-dotnet and jco are under +active development. + +--- + +## Reference: How It Would Work (Future) + +The following documentation describes the **intended** build process for when the +tooling matures. The code compiles and transpiles successfully, but cannot execute. + +### Understanding WASI Versions + +.NET 10 produces **WASI Component Model** (P2/P3) binaries, not WASI Preview 1 (P1) format: + +| Format | Version Magic | Compatible Runtimes | +| --------------- | ------------- | ----------------------- | +| WASI P1 | `0x01` | Node.js WASI, wasmer | +| Component Model | `0x0d` | wasmtime, jco transpile | + +Signal K currently uses WASI P1. To run .NET plugins, either: + +1. **Upgrade runtime** to wasmtime with component support +2. **Transpile** with `jco` to JavaScript + P1 WASM + +### Step 1: Install Prerequisites + +```powershell +# Install .NET 10 SDK (https://dotnet.microsoft.com/download/dotnet/10.0) +# Verify installation +dotnet --version # Should show 10.0.x + +# Install componentize-dotnet templates +dotnet new install BytecodeAlliance.Componentize.DotNet.Templates +``` + +### Step 2: Create Project Structure + +``` +example-anchor-watch-dotnet/ +├── AnchorWatch.csproj # Project file with componentize-dotnet +├── PluginImpl.cs # Plugin implementation +├── nuget.config # NuGet feed for LLVM compiler +├── patch-threadstatic.ps1 # Build-time patcher (Windows) +└── wit/ + └── signalk-plugin.wit # WIT interface definition +``` + +### Step 3: Create WIT Interface + +Create `wit/signalk-plugin.wit`: + +```wit +package signalk:plugin@1.0.0; + +/// Plugin interface - exported by WASM plugin +interface plugin { + /// Returns unique plugin identifier + plugin-id: func() -> string; + + /// Returns human-readable plugin name + plugin-name: func() -> string; + + /// Returns JSON Schema for plugin configuration + plugin-schema: func() -> string; + + /// Start the plugin with JSON configuration + /// Returns 0 on success, non-zero on error + plugin-start: func(config: string) -> s32; + + /// Stop the plugin + /// Returns 0 on success, non-zero on error + plugin-stop: func() -> s32; +} + +/// Signal K API - imported from host +interface signalk-api { + /// Log debug message + sk-debug: func(message: string); + + /// Set plugin status message + sk-set-status: func(message: string); + + /// Set plugin error message + sk-set-error: func(message: string); + + /// Emit a Signal K delta message + sk-handle-message: func(delta-json: string); + + /// Register a PUT handler for a path + sk-register-put-handler: func(context: string, path: string) -> s32; +} + +/// World definition - connects imports and exports +world signalk-plugin { + import signalk-api; + export plugin; +} +``` + +### Step 4: Create Project File + +Create `AnchorWatch.csproj`: + +```xml + + + + net10.0 + wasi-wasm + false + true + true + true + Library + true + enable + true + false + false + + + + + + + + + + + + + + + + + + + + $(IntermediateOutputPath)wit_bindgen\SignalkPlugin.cs + + + + + +``` + +### Step 5: Create NuGet Config + +Create `nuget.config` for the experimental LLVM compiler: + +```xml + + + + + + + + +``` + +### Step 6: Create Build Patcher + +Create `patch-threadstatic.ps1` (required to fix wit-bindgen issues): + +```powershell +# Patch wit-bindgen generated C# files for WASI compatibility +# Fixes: +# 1. ThreadStaticAttribute missing in WASI single-threaded environment +# 2. Missing using statements in generated code +param([string]$FilePath) + +# Get the directory containing the generated files +$dir = Split-Path $FilePath -Parent + +# Patch all .cs files in the wit_bindgen directory +Get-ChildItem -Path $dir -Filter "*.cs" | ForEach-Object { + $file = $_.FullName + $content = Get-Content $file -Raw + $modified = $false + + # Add missing using statements if not present + if ($content -match '#nullable enable' -and $content -notmatch 'using System;(\r?\n)') { + $content = $content -replace '(#nullable enable\r?\n)', "`$1using System;`nusing System.Collections.Generic;`n" + $modified = $true + } + + # For SignalkPlugin.cs specifically, add ThreadStatic stub + if ($_.Name -eq 'SignalkPlugin.cs') { + if ($content -match '\[ThreadStatic\]') { + $content = $content -replace '\[ThreadStatic\]', '[global::System.ThreadStatic]' + $modified = $true + } + + if ($content -notmatch '// WASI ThreadStatic stub') { + $stub = @" +// WASI ThreadStatic stub - single-threaded environment +namespace System { + [global::System.AttributeUsage(global::System.AttributeTargets.Field, Inherited = false)] + public sealed class ThreadStaticAttribute : global::System.Attribute { } +} + +"@ + $content = $content -replace 'namespace SignalkPluginWorld', ($stub + 'namespace SignalkPluginWorld') + $modified = $true + } + } + + if ($modified) { + Set-Content $file $content -NoNewline + Write-Host "Patched $($_.Name)" + } +} + +Write-Host "Patching complete" +``` + +### Step 7: Implement Plugin + +Create `PluginImpl.cs`: + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; +using SignalkPluginWorld.wit.exports.signalk.plugin.v1_0_0; +using SignalkPluginWorld.wit.imports.signalk.plugin.v1_0_0; + +namespace AnchorWatch; + +/// +/// Anchor Watch Plugin - monitors vessel position relative to anchor +/// +public class PluginImpl : IPlugin +{ + private static PluginConfig? _config; + private static bool _isRunning; + + public static string PluginId() => "anchor-watch-dotnet"; + + public static string PluginName() => "Anchor Watch (.NET)"; + + public static string PluginSchema() => """ + { + "type": "object", + "title": "Anchor Watch Configuration", + "properties": { + "maxRadius": { + "type": "number", + "title": "Maximum Radius (meters)", + "description": "Alert when vessel drifts beyond this radius from anchor", + "default": 50 + }, + "checkInterval": { + "type": "number", + "title": "Check Interval (seconds)", + "default": 10 + } + } + } + """; + + public static int PluginStart(string config) + { + try + { + SignalkApiInterop.SkDebug($"Starting Anchor Watch with config: {config}"); + + // Parse configuration + _config = string.IsNullOrEmpty(config) + ? new PluginConfig() + : JsonSerializer.Deserialize(config, SourceGenerationContext.Default.PluginConfig) + ?? new PluginConfig(); + + _isRunning = true; + + SignalkApiInterop.SkSetStatus($"Monitoring anchor (radius: {_config.MaxRadius}m)"); + SignalkApiInterop.SkDebug("Anchor Watch started successfully"); + + return 0; // Success + } + catch (Exception ex) + { + SignalkApiInterop.SkSetError($"Failed to start: {ex.Message}"); + return 1; // Error + } + } + + public static int PluginStop() + { + _isRunning = false; + SignalkApiInterop.SkSetStatus("Stopped"); + SignalkApiInterop.SkDebug("Anchor Watch stopped"); + return 0; + } +} + +/// +/// Plugin configuration +/// +public class PluginConfig +{ + [JsonPropertyName("maxRadius")] + public double MaxRadius { get; set; } = 50; + + [JsonPropertyName("checkInterval")] + public int CheckInterval { get; set; } = 10; +} + +/// +/// JSON source generator for AOT compatibility +/// +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(PluginConfig))] +internal partial class SourceGenerationContext : JsonSerializerContext +{ +} +``` + +### Step 8: Build + +```powershell +cd examples/wasm-plugins/example-anchor-watch-dotnet + +# Clean previous build +Remove-Item -Recurse -Force obj -ErrorAction SilentlyContinue + +# Build +dotnet build + +# Output location +# bin/Debug/net10.0/wasi-wasm/publish/AnchorWatch.wasm +``` + +Expected output: + +``` +Wiederherstellung abgeschlossen (1.7s) + AnchorWatch net10.0 wasi-wasm erfolgreich mit 1 Warnung(en) (16.9s) +``` + +The warning about `ThreadStaticAttribute` conflict is expected and harmless. + +### Step 9: Verify Output + +```powershell +# Check file size +dir bin\Debug\net10.0\wasi-wasm\publish\AnchorWatch.wasm +# ~20 MB + +# Verify WIT interface (requires jco) +npx @bytecodealliance/jco wit bin\Debug\net10.0\wasi-wasm\publish\AnchorWatch.wasm +``` + +Expected WIT output: + +```wit +package root:component; + +world root { + import wasi:cli/environment@0.2.0; + import wasi:io/streams@0.2.0; + ... + import signalk:plugin/signalk-api@1.0.0; + + export signalk:plugin/plugin@1.0.0; +} +``` + +## Troubleshooting .NET Builds + +### Error: ThreadStaticAttribute not found + +The `patch-threadstatic.ps1` script should fix this automatically. If it persists: + +1. Delete the `obj` folder completely +2. Ensure the patch script path is correct in `.csproj` +3. Run `dotnet build` again + +### Error: Microsoft.DotNet.ILCompiler.LLVM not found + +Ensure `nuget.config` is present with the `dotnet-experimental` feed. + +### Error: List<> or Span<> not found + +The patch script adds missing `using` statements. If errors persist, manually add to the generated files: + +```csharp +using System; +using System.Collections.Generic; +``` + +### Large binary size (~20 MB) + +This is expected for NativeAOT-LLVM compilation. The binary includes: + +- .NET runtime (trimmed) +- WASI Component Model adapter +- Your plugin code + +Future optimizations may reduce this. + +## Using the Signal K API + +The WIT-generated bindings provide type-safe access to Signal K APIs: + +```csharp +using SignalkPluginWorld.wit.imports.signalk.plugin.v1_0_0; + +// Log debug message +SignalkApiInterop.SkDebug("Debug message"); + +// Set status +SignalkApiInterop.SkSetStatus("Running"); + +// Set error +SignalkApiInterop.SkSetError("Something went wrong"); + +// Emit delta +var delta = """ +{ + "context": "vessels.self", + "updates": [{ + "source": {"label": "anchor-watch-dotnet", "type": "plugin"}, + "timestamp": "2025-12-02T10:00:00.000Z", + "values": [{ + "path": "navigation.anchor.position", + "value": {"latitude": 60.1234, "longitude": 24.5678} + }] + }] +} +"""; +SignalkApiInterop.SkHandleMessage(delta); + +// Register PUT handler +SignalkApiInterop.SkRegisterPutHandler("vessels.self", "navigation.anchor.position"); +``` + +## Runtime Integration (Coming Soon) + +The .NET WASM component uses WASI Component Model format. To run it in Signal K: + +**Option 1: Wasmtime Runtime** +Replace Node.js WASI with wasmtime (supports Component Model natively). + +**Option 2: jco Transpilation** +Transpile to JavaScript + WASI P1: + +```bash +npx @bytecodealliance/jco transpile AnchorWatch.wasm -o ./transpiled +``` + +This generates JavaScript bindings that work with the current Node.js runtime. + +## Additional Resources + +See the example-anchor-watch-dotnet example in `examples/wasm-plugins/example-anchor-watch-dotnet/` for the complete working example. diff --git a/docs/develop/plugins/wasm/go.md b/docs/develop/plugins/wasm/go.md new file mode 100644 index 000000000..ee943757d --- /dev/null +++ b/docs/develop/plugins/wasm/go.md @@ -0,0 +1,238 @@ +--- +title: Go/TinyGo Plugins +--- + +# Creating Go/TinyGo Plugins + +Go plugins use TinyGo, a Go compiler designed for small environments including WebAssembly. + +## Step 1: Install TinyGo + +Download from https://tinygo.org/getting-started/install/ + +```bash +# Verify installation +tinygo version +``` + +## Step 2: Create Project Structure + +``` +my-go-plugin/ +├── main.go # Plugin code +├── go.mod # Go module +├── package.json # npm package manifest +├── public/ # Static web assets (optional) +│ └── index.html +└── README.md +``` + +## Step 3: Create go.mod + +```go +module my-go-plugin + +go 1.21 +``` + +## Step 4: Create main.go + +```go +package main + +import ( + "encoding/json" + "unsafe" +) + +// FFI Imports from Signal K host +//go:wasmimport env sk_debug +func sk_debug(ptr *byte, len uint32) + +//go:wasmimport env sk_set_status +func sk_set_status(ptr *byte, len uint32) + +//go:wasmimport env sk_set_error +func sk_set_error(ptr *byte, len uint32) + +//go:wasmimport env sk_handle_message +func sk_handle_message(ptr *byte, len uint32) + +// Helper wrappers +func debug(msg string) { + if len(msg) > 0 { + sk_debug(unsafe.StringData(msg), uint32(len(msg))) + } +} + +func setStatus(msg string) { + if len(msg) > 0 { + sk_set_status(unsafe.StringData(msg), uint32(len(msg))) + } +} + +func handleMessage(msg string) { + if len(msg) > 0 { + sk_handle_message(unsafe.StringData(msg), uint32(len(msg))) + } +} + +// Memory allocation for string passing +//export allocate +func allocate(size uint32) *byte { + buf := make([]byte, size) + return &buf[0] +} + +//export deallocate +func deallocate(ptr *byte, size uint32) { + // With leaking GC, memory is reclaimed when module unloads +} + +// Plugin exports +//export plugin_id +func plugin_id(outPtr *byte, maxLen uint32) int32 { + return writeString("my-go-plugin", outPtr, maxLen) +} + +//export plugin_name +func plugin_name(outPtr *byte, maxLen uint32) int32 { + return writeString("My Go Plugin", outPtr, maxLen) +} + +//export plugin_schema +func plugin_schema(outPtr *byte, maxLen uint32) int32 { + schema := `{"type":"object","properties":{}}` + return writeString(schema, outPtr, maxLen) +} + +//export plugin_start +func plugin_start(configPtr *byte, configLen uint32) int32 { + debug("Go plugin starting") + setStatus("Running") + + // Emit a test delta + delta := `{"updates":[{"values":[{"path":"test.goPlugin","value":"hello from Go"}]}]}` + handleMessage(delta) + + return 0 +} + +//export plugin_stop +func plugin_stop() int32 { + debug("Go plugin stopped") + setStatus("Stopped") + return 0 +} + +// Helper: write string to output buffer +func writeString(s string, ptr *byte, maxLen uint32) int32 { + bytes := []byte(s) + length := len(bytes) + if uint32(length) > maxLen { + length = int(maxLen) + } + dst := unsafe.Slice(ptr, length) + copy(dst, bytes[:length]) + return int32(length) +} + +// Required for TinyGo WASM +func main() {} +``` + +## Step 5: Create package.json + +```json +{ + "name": "my-go-wasm-plugin", + "version": "0.1.0", + "description": "My Go WASM plugin", + "keywords": ["signalk-wasm-plugin"], + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { + "dataRead": true, + "dataWrite": true, + "storage": "vfs-only" + } +} +``` + +> **Note**: The package name can be anything - there's no requirement for `@signalk/` scope. The `wasmManifest` field is what identifies this as a WASM plugin. + +## Step 6: Build + +```bash +# Release build (smaller, optimized) +tinygo build -o plugin.wasm -target=wasip1 -gc=leaking -no-debug main.go + +# Debug build (for development) +tinygo build -o plugin.wasm -target=wasip1 main.go +``` + +## Step 7: Install + +**Option 1: Symlink (Recommended for Development)** + +```bash +cd ~/.signalk/node_modules +ln -s /path/to/your/my-go-wasm-plugin my-go-wasm-plugin +``` + +**Option 2: Direct Copy** + +```bash +mkdir -p ~/.signalk/node_modules/my-go-wasm-plugin +cp plugin.wasm package.json ~/.signalk/node_modules/my-go-wasm-plugin/ +``` + +## Go FFI Interface Reference + +Signal K provides these FFI imports in the `env` module: + +| Function | Parameters | Description | +| ------------------------------- | ------------ | ----------------------------- | +| `sk_debug` | `(ptr, len)` | Log debug message | +| `sk_set_status` | `(ptr, len)` | Set plugin status | +| `sk_set_error` | `(ptr, len)` | Set error message | +| `sk_handle_message` | `(ptr, len)` | Emit delta message | +| `sk_register_resource_provider` | `(ptr, len)` | Register as resource provider | + +## Required Plugin Exports + +Your plugin MUST export: + +| Export | Signature | Description | +| --------------- | ------------------------------------ | ------------------ | +| `plugin_id` | `(out_ptr, max_len) -> len` | Return plugin ID | +| `plugin_name` | `(out_ptr, max_len) -> len` | Return plugin name | +| `plugin_schema` | `(out_ptr, max_len) -> len` | Return JSON schema | +| `plugin_start` | `(config_ptr, config_len) -> status` | Start plugin | +| `plugin_stop` | `() -> status` | Stop plugin | +| `allocate` | `(size) -> ptr` | Allocate memory | +| `deallocate` | `(ptr, size)` | Free memory | + +## Optional Plugin Exports + +Your plugin MAY export: + +| Export | Signature | Description | +| ---------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `poll` | `() -> status` | Called every 1 second while plugin is running. Useful for polling hardware, sockets, or external systems. Return 0 for success, non-zero for errors. | +| `http_endpoints` | `() -> json` | Return JSON array of HTTP endpoint definitions | +| `delta_handler` | `(delta_ptr, delta_len)` | Receives Signal K deltas as JSON strings. Called for every delta emitted by the server. | + +## TinyGo Limitations + +TinyGo is a subset of Go. Notable limitations: + +- No reflection (limited `encoding/json` support) +- No goroutines with WASI Preview 1 +- Garbage collector options: `leaking` (recommended), `conservative` +- Some standard library packages unavailable + +See https://tinygo.org/docs/reference/lang-support/ for details. + +## Additional Resources + +See the example-routes-waypoints plugin in `examples/wasm-plugins/example-routes-waypoints/` for a complete resource provider plugin. diff --git a/docs/develop/plugins/wasm/http_endpoints.md b/docs/develop/plugins/wasm/http_endpoints.md new file mode 100644 index 000000000..174a3bd02 --- /dev/null +++ b/docs/develop/plugins/wasm/http_endpoints.md @@ -0,0 +1,183 @@ +--- +title: HTTP Endpoints +--- + +# HTTP Endpoints + +WASM plugins can register custom HTTP endpoints to provide REST APIs or serve dynamic content. This is useful for: + +- Providing plugin-specific APIs +- Implementing webhook receivers +- Creating custom data queries +- Building interactive dashboards + +## Registering HTTP Endpoints + +Export an `http_endpoints()` function that returns a JSON array of endpoint definitions: + +```typescript +// assembly/index.ts +export function http_endpoints(): string { + return `[ + { + "method": "GET", + "path": "/api/data", + "handler": "handle_get_data" + }, + { + "method": "POST", + "path": "/api/update", + "handler": "handle_post_update" + } + ]` +} +``` + +## Implementing HTTP Handlers + +Handler functions receive a request context and return an HTTP response: + +```typescript +export function handle_get_data(requestPtr: usize, requestLen: usize): string { + // 1. Decode request from WASM memory + const requestBytes = new Uint8Array(i32(requestLen)) + for (let i: i32 = 0; i < i32(requestLen); i++) { + requestBytes[i] = load(requestPtr + i) + } + const requestJson = String.UTF8.decode(requestBytes.buffer) + + // 2. Parse request (contains method, path, query, params, body, headers) + // Simple example: extract query parameter + let filter = '' + const filterIndex = requestJson.indexOf('"filter"') + if (filterIndex >= 0) { + // Extract the filter value from JSON + // (In production, use proper JSON parsing) + } + + // 3. Process request and build response data + const data = { + items: [ + { id: 1, value: 'Item 1' }, + { id: 2, value: 'Item 2' } + ], + count: 2 + } + const bodyJson = JSON.stringify(data) + + // 4. Escape JSON for embedding in response string + const escapedBody = bodyJson + .replaceAll('"', '\\"') + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + + // 5. Return HTTP response (status, headers, body) + return `{ + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": "${escapedBody}" + }` +} + +export function handle_post_update( + requestPtr: usize, + requestLen: usize +): string { + const requestBytes = new Uint8Array(i32(requestLen)) + for (let i: i32 = 0; i < i32(requestLen); i++) { + requestBytes[i] = load(requestPtr + i) + } + const requestJson = String.UTF8.decode(requestBytes.buffer) + + // Process POST body and update state + // ... + + return `{ + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": "{\\"success\\":true}" + }` +} +``` + +## Request Context Format + +The request context is a JSON object with: + +```json +{ + "method": "GET", + "path": "/api/logs", + "query": { + "lines": "100", + "filter": "error" + }, + "params": {}, + "body": null, + "headers": { + "user-agent": "Mozilla/5.0...", + "accept": "application/json" + } +} +``` + +## Response Format + +Handler functions must return a JSON string with: + +```json +{ + "statusCode": 200, + "headers": { + "Content-Type": "application/json", + "Cache-Control": "no-cache" + }, + "body": "{\"data\": \"value\"}" +} +``` + +**Important Notes:** + +- The `body` field must be a JSON-escaped string +- Use double escaping for quotes: `\\"` not `"` +- Endpoints are mounted at `/plugins/your-plugin-id/api/...` +- From browser, fetch from absolute path: `/plugins/your-plugin-id/api/logs` + +## String Memory Management + +The server uses the **AssemblyScript loader** for automatic string handling: + +**For plugin metadata (id, name, schema, http_endpoints):** + +- Return AssemblyScript strings directly +- Server automatically decodes with `__getString()` + +**For HTTP handlers:** + +- Receive: `(requestPtr: usize, requestLen: usize)` - raw memory pointer +- Manually decode UTF-8 bytes from WASM memory +- Return: AssemblyScript string with escaped JSON +- Server automatically decodes with `__getString()` + +**Why manual decoding for handlers?** +The request is passed as raw UTF-8 bytes for efficiency, but the response is returned as an AssemblyScript string (UTF-16LE) which the loader decodes automatically. + +## Testing Your Endpoints + +```bash +# Test GET endpoint +curl http://localhost:3000/plugins/my-plugin/api/data?filter=test + +# Test POST endpoint +curl -X POST http://localhost:3000/plugins/my-plugin/api/update \ + -H "Content-Type: application/json" \ + -d '{"value": 123}' +``` + +## Security Considerations + +- Endpoints are sandboxed - no direct file system access +- Memory is isolated - cannot access other plugins +- Validate all input from requests +- Implement authentication if handling sensitive data +- Set appropriate CORS headers if needed diff --git a/docs/develop/plugins/wasm/integration_guide.md b/docs/develop/plugins/wasm/integration_guide.md new file mode 100644 index 000000000..74b450280 --- /dev/null +++ b/docs/develop/plugins/wasm/integration_guide.md @@ -0,0 +1,400 @@ +--- +title: Integration Guide for WASM Plugins +--- + +# Integration Guide for WASM Plugins + +## Static File Serving + +Plugins can serve HTML, CSS, JavaScript and other static files: + +**Structure:** + +``` +@signalk/my-plugin/ +├── public/ # Automatically served at /plugins/my-plugin/ +│ ├── index.html +│ ├── style.css +│ └── app.js +├── plugin.wasm +└── package.json +``` + +**Access:** `http://localhost:3000/plugins/my-plugin/` serves `public/index.html` + +## Resource Providers + +WASM plugins can act as **resource providers** for Signal K resources like weather data, routes, waypoints, or custom resource types. + +### Enabling Resource Provider Capability + +Add `resourceProvider: true` to your package.json: + +```json +{ + "wasmCapabilities": { + "network": true, + "dataRead": true, + "dataWrite": true, + "resourceProvider": true + } +} +``` + +### Registering as a Resource Provider + +#### AssemblyScript + +```typescript +import { registerResourceProvider } from '@signalk/assemblyscript-plugin-sdk/assembly/resources' + +// In plugin start(): +if (!registerResourceProvider('weather-forecasts')) { + setError('Failed to register as resource provider') + return 1 +} +``` + +#### Rust + +```rust +#[link(wasm_import_module = "env")] +extern "C" { + fn sk_register_resource_provider(type_ptr: *const u8, type_len: usize) -> i32; +} + +pub fn register_resource_provider(resource_type: &str) -> bool { + let bytes = resource_type.as_bytes(); + unsafe { sk_register_resource_provider(bytes.as_ptr(), bytes.len()) == 1 } +} + +// In plugin_start(): +if !register_resource_provider("weather-forecasts") { + // Registration failed + return 1; +} +``` + +### Implementing Resource Handlers + +After registering, your plugin must export these handler functions: + +#### `resources_list_resources` - List resources matching a query + +**AssemblyScript:** + +```typescript +export function resources_list_resources(queryJson: string): string { + // queryJson: {"bbox": [...], "distance": 1000, ...} + // Return JSON object: {"resource-id-1": {...}, "resource-id-2": {...}} + return '{"forecast-1": {"name": "Current Weather", "type": "weather"}}' +} +``` + +**Rust:** + +```rust +#[no_mangle] +pub extern "C" fn resources_list_resources( + request_ptr: *const u8, request_len: usize, + response_ptr: *mut u8, response_max_len: usize, +) -> i32 { + // Parse query, build response + let response = r#"{"forecast-1": {"name": "Current Weather"}}"#; + write_string(response, response_ptr, response_max_len) +} +``` + +#### `resources_get_resource` - Get a single resource + +**AssemblyScript:** + +```typescript +export function resources_get_resource(requestJson: string): string { + // requestJson: {"id": "forecast-1", "property": null} + return '{"name": "Current Weather", "temperature": 20.5, "humidity": 0.65}' +} +``` + +#### `resources_set_resource` - Create or update a resource + +**AssemblyScript:** + +```typescript +export function resources_set_resource(requestJson: string): string { + // requestJson: {"id": "forecast-1", "value": {...}} + // Return empty string on success, or error message + return '' +} +``` + +#### `resources_delete_resource` - Delete a resource + +**AssemblyScript:** + +```typescript +export function resources_delete_resource(requestJson: string): string { + // requestJson: {"id": "forecast-1"} + return '' +} +``` + +### Accessing Resources via HTTP + +Once registered, resources are available at: + +``` +GET /signalk/v2/api/resources/{type} # List all +GET /signalk/v2/api/resources/{type}/{id} # Get one +POST /signalk/v2/api/resources/{type}/{id} # Create/update +DELETE /signalk/v2/api/resources/{type}/{id} # Delete +``` + +### Standard vs Custom Resource Types + +Signal K defines standard resource types with validation: + +- `routes` - Navigation routes +- `waypoints` - Navigation waypoints +- `notes` - Freeform notes +- `regions` - Geographic regions +- `charts` - Chart metadata + +Custom types (like `weather-forecasts`) have no schema validation and can contain any JSON structure. + +## Weather Providers + +WASM plugins can act as **weather providers** for Signal K's specialized Weather API. + +### Weather Provider vs Resource Provider + +| Feature | Weather Provider | Resource Provider | +| ---------- | ------------------------------------------ | ---------------------------------- | +| API Path | `/signalk/v2/api/weather/*` | `/signalk/v2/api/resources/{type}` | +| Methods | getObservations, getForecasts, getWarnings | list, get, set, delete | +| Use Case | Standardized weather data | Generic data storage | +| Capability | `weatherProvider: true` | `resourceProvider: true` | +| FFI | `sk_register_weather_provider` | `sk_register_resource_provider` | + +### Enabling Weather Provider Capability + +```json +{ + "wasmCapabilities": { + "network": true, + "dataWrite": true, + "weatherProvider": true + } +} +``` + +### Implementing Weather Handler Exports + +Your plugin must export these handler functions: + +#### `weather_get_observations` - Get current weather observations + +```typescript +export function weather_get_observations(requestJson: string): string { + // requestJson: {"position": {"latitude": 60.17, "longitude": 24.94}, "options": {...}} + return ( + '[{"date":"2025-01-01T00:00:00Z","type":"observation","description":"Clear sky",' + + '"outside":{"temperature":280.15,"relativeHumidity":0.65,"pressure":101300,"cloudCover":0.1},' + + '"wind":{"speedTrue":5.0,"directionTrue":1.57}}]' + ) +} +``` + +#### `weather_get_forecasts` - Get weather forecasts + +```typescript +export function weather_get_forecasts(requestJson: string): string { + // requestJson: {"position": {...}, "type": "daily"|"point", "options": {"maxCount": 7}} + return '[{"date":"...","type":"daily","outside":{...},"wind":{...}}]' +} +``` + +#### `weather_get_warnings` - Get weather warnings/alerts + +```typescript +export function weather_get_warnings(requestJson: string): string { + // requestJson: {"position": {...}} + return '[]' +} +``` + +### Weather Data Format + +#### Observation/Forecast Object + +```json +{ + "date": "2025-12-05T10:00:00.000Z", + "type": "observation", + "description": "light rain", + "outside": { + "temperature": 275.15, + "minTemperature": 273.0, + "maxTemperature": 278.0, + "feelsLikeTemperature": 272.0, + "relativeHumidity": 0.85, + "pressure": 101300, + "cloudCover": 0.75 + }, + "wind": { + "speedTrue": 5.2, + "directionTrue": 3.14, + "gust": 8.0 + } +} +``` + +Units: + +- Temperature: Kelvin +- Humidity: Ratio (0-1) +- Pressure: Pascals +- Wind speed: m/s +- Wind direction: Radians + +#### Warning Object + +```json +{ + "startTime": "2025-12-05T10:00:00.000Z", + "endTime": "2025-12-05T18:00:00.000Z", + "details": "Strong wind warning", + "source": "Weather Service", + "type": "Warning" +} +``` + +### Accessing Weather Data via HTTP + +```bash +# List providers +curl http://localhost:3000/signalk/v2/api/weather/_providers + +# Get observations for a location +curl "http://localhost:3000/signalk/v2/api/weather/observations?lat=60.17&lon=24.94" + +# Get daily forecasts +curl "http://localhost:3000/signalk/v2/api/weather/forecasts/daily?lat=60.17&lon=24.94" + +# Get point-in-time forecasts +curl "http://localhost:3000/signalk/v2/api/weather/forecasts/point?lat=60.17&lon=24.94" + +# Get weather warnings +curl "http://localhost:3000/signalk/v2/api/weather/warnings?lat=60.17&lon=24.94" +``` + +## Radar Providers + +WASM plugins can act as **radar providers** for Signal K's Radar API at `/signalk/v2/api/vessels/self/radars`. + +### Enabling Radar Provider Capability + +```json +{ + "signalk": { + "wasmCapabilities": { + "radarProvider": true, + "network": true + } + } +} +``` + +### Registering as a Radar Provider + +```typescript +// Declare the host function +@external("env", "sk_register_radar_provider") +declare function sk_register_radar_provider(namePtr: usize, nameLen: i32): i32; + +export function start(configJson: string): i32 { + const name = "My Radar Plugin"; + const nameBytes = String.UTF8.encode(name); + const result = sk_register_radar_provider( + changetype(nameBytes), + nameBytes.byteLength + ); + + if (result === 0) { + sk_set_plugin_error("Failed to register as radar provider", 38); + return 1; + } + + return 0; +} +``` + +### Required Handler Exports + +```typescript +// Return JSON array of radar IDs this provider manages +export function radar_get_radars(): string { + return JSON.stringify(['radar-0', 'radar-1']) +} + +// Return RadarInfo JSON for a specific radar +export function radar_get_radar_info(requestJson: string): string { + const info = { + id: 'radar-0', + name: 'Furuno DRS4D-NXT', + brand: 'Furuno', + status: 'transmit', + spokesPerRevolution: 2048, + maxSpokeLen: 1024, + range: 2000, + controls: { + gain: { auto: false, value: 50 }, + sea: { auto: true, value: 30 } + } + } + return JSON.stringify(info) +} +``` + +### RadarInfo Interface + +```typescript +interface RadarInfo { + id: string // Unique radar ID + name: string // Display name + brand?: string // Manufacturer + status: 'off' | 'standby' | 'transmit' | 'warming' + spokesPerRevolution: number // Spokes per rotation + maxSpokeLen: number // Max spoke samples + range: number // Current range (meters) + controls: RadarControls // Current control values + legend?: LegendEntry[] // Color legend for display + streamUrl?: string // Optional external WebSocket URL +} +``` + +### Streaming Radar Spokes + +Radar spoke data arrives at ~60Hz (2048 spokes/rotation × 30-60 RPM). Plugins stream binary protobuf data directly to clients: + +```typescript +import { sk_radar_emit_spokes } from './signalk-api' + +// Called when spoke data received via UDP multicast +function processSpokeData(radarId: string, spokeProtobuf: Uint8Array): void { + sk_radar_emit_spokes(radarId, spokeProtobuf.buffer, spokeProtobuf.byteLength) +} +``` + +Clients connect to the WebSocket stream: + +```javascript +const wsUrl = `ws://${location.host}/signalk/v2/api/vessels/self/radars/radar-0/stream` +const ws = new WebSocket(wsUrl) +ws.binaryType = 'arraybuffer' + +ws.onmessage = (event) => { + const spokeData = new Uint8Array(event.data) + // Decode and render spoke +} +``` diff --git a/docs/develop/plugins/wasm/rust.md b/docs/develop/plugins/wasm/rust.md new file mode 100644 index 000000000..7d4045743 --- /dev/null +++ b/docs/develop/plugins/wasm/rust.md @@ -0,0 +1,354 @@ +--- +title: Rust Plugins +--- + +# Creating Rust Plugins + +Rust is excellent for WASM plugins due to its zero-cost abstractions, memory safety, and mature WASM tooling. Signal K Rust plugins use **buffer-based FFI** for string passing, which differs from AssemblyScript's automatic string handling. + +## Rust vs AssemblyScript: Key Differences + +| Aspect | AssemblyScript | Rust | +| ----------------- | ----------------------- | ------------------------------- | +| String passing | Automatic via AS loader | Manual buffer-based FFI | +| Memory management | AS runtime handles | `allocate`/`deallocate` exports | +| Binary size | 3-10 KB | 50-200 KB | +| Target | `wasm32` (AS compiler) | `wasm32-wasip1` | + +## Step 1: Project Structure + +Create a new Rust library project: + +```bash +cargo new --lib example-anchor-watch-rust +cd example-anchor-watch-rust +``` + +## Step 2: Configure Cargo.toml + +```toml +[package] +name = "anchor_watch_rust" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +strip = true # Strip symbols +``` + +## Step 3: Implement Plugin (src/lib.rs) + +```rust +use std::cell::RefCell; +use serde::{Deserialize, Serialize}; + +// ============================================================================= +// FFI Imports - These MUST match what the Signal K runtime provides in "env" +// ============================================================================= + +#[link(wasm_import_module = "env")] +extern "C" { + fn sk_debug(ptr: *const u8, len: usize); + fn sk_set_status(ptr: *const u8, len: usize); + fn sk_set_error(ptr: *const u8, len: usize); + fn sk_handle_message(ptr: *const u8, len: usize); + fn sk_register_put_handler( + context_ptr: *const u8, context_len: usize, + path_ptr: *const u8, path_len: usize + ) -> i32; +} + +// ============================================================================= +// Helper wrappers for FFI functions +// ============================================================================= + +fn debug(msg: &str) { + unsafe { sk_debug(msg.as_ptr(), msg.len()); } +} + +fn set_status(msg: &str) { + unsafe { sk_set_status(msg.as_ptr(), msg.len()); } +} + +fn set_error(msg: &str) { + unsafe { sk_set_error(msg.as_ptr(), msg.len()); } +} + +fn handle_message(msg: &str) { + unsafe { sk_handle_message(msg.as_ptr(), msg.len()); } +} + +fn register_put_handler(context: &str, path: &str) -> i32 { + unsafe { + sk_register_put_handler( + context.as_ptr(), context.len(), + path.as_ptr(), path.len() + ) + } +} + +// ============================================================================= +// Memory Allocation - REQUIRED for buffer-based string passing +// ============================================================================= + +/// Allocate memory for string passing from host +#[no_mangle] +pub extern "C" fn allocate(size: usize) -> *mut u8 { + let mut buf = Vec::with_capacity(size); + let ptr = buf.as_mut_ptr(); + std::mem::forget(buf); + ptr +} + +/// Deallocate memory +#[no_mangle] +pub extern "C" fn deallocate(ptr: *mut u8, size: usize) { + unsafe { + let _ = Vec::from_raw_parts(ptr, 0, size); + } +} + +// ============================================================================= +// Plugin State +// ============================================================================= + +thread_local! { + static STATE: RefCell = RefCell::new(PluginState::default()); +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PluginConfig { + #[serde(default)] + max_radius: f64, +} + +#[derive(Debug, Default)] +struct PluginState { + config: PluginConfig, + is_running: bool, +} + +// ============================================================================= +// Plugin Exports - Core plugin interface +// ============================================================================= + +static PLUGIN_ID: &str = "my-rust-plugin"; +static PLUGIN_NAME: &str = "My Rust Plugin"; +static PLUGIN_SCHEMA: &str = r#"{ + "type": "object", + "properties": { + "maxRadius": { + "type": "number", + "title": "Max Radius", + "default": 50 + } + } +}"#; + +/// Return the plugin ID (buffer-based) +#[no_mangle] +pub extern "C" fn plugin_id(out_ptr: *mut u8, out_max_len: usize) -> i32 { + write_string(PLUGIN_ID, out_ptr, out_max_len) +} + +/// Return the plugin name (buffer-based) +#[no_mangle] +pub extern "C" fn plugin_name(out_ptr: *mut u8, out_max_len: usize) -> i32 { + write_string(PLUGIN_NAME, out_ptr, out_max_len) +} + +/// Return the plugin JSON schema (buffer-based) +#[no_mangle] +pub extern "C" fn plugin_schema(out_ptr: *mut u8, out_max_len: usize) -> i32 { + write_string(PLUGIN_SCHEMA, out_ptr, out_max_len) +} + +/// Start the plugin with configuration +#[no_mangle] +pub extern "C" fn plugin_start(config_ptr: *const u8, config_len: usize) -> i32 { + // Read config from buffer + let config_json = unsafe { + let slice = std::slice::from_raw_parts(config_ptr, config_len); + String::from_utf8_lossy(slice).to_string() + }; + + // Parse configuration + let parsed_config: PluginConfig = match serde_json::from_str(&config_json) { + Ok(c) => c, + Err(e) => { + set_error(&format!("Failed to parse config: {}", e)); + return 1; + } + }; + + // Update state + STATE.with(|state| { + let mut s = state.borrow_mut(); + s.config = parsed_config; + s.is_running = true; + }); + + debug("Plugin started successfully"); + set_status("Running"); + + 0 // Success +} + +/// Stop the plugin +#[no_mangle] +pub extern "C" fn plugin_stop() -> i32 { + STATE.with(|state| { + state.borrow_mut().is_running = false; + }); + + debug("Plugin stopped"); + set_status("Stopped"); + + 0 // Success +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/// Write string to output buffer, return bytes written +fn write_string(s: &str, ptr: *mut u8, max_len: usize) -> i32 { + let bytes = s.as_bytes(); + let len = bytes.len().min(max_len); + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, len); + } + len as i32 +} +``` + +## Step 4: Create package.json + +```json +{ + "name": "my-rust-wasm-plugin", + "version": "0.1.0", + "description": "My Rust WASM plugin for Signal K", + "keywords": ["signalk-wasm-plugin"], + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { + "network": false, + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "putHandlers": true + }, + "author": "Your Name", + "license": "Apache-2.0" +} +``` + +> **Note**: The package name can be anything - there's no requirement for `@signalk/` scope. The `wasmManifest` field is what identifies this as a WASM plugin. + +## Step 5: Build + +```bash +# Build with WASI Preview 1 target (required for Signal K) +cargo build --release --target wasm32-wasip1 + +# Copy to plugin.wasm +cp target/wasm32-wasip1/release/my_rust_plugin.wasm plugin.wasm +``` + +> **Important**: Use `wasm32-wasip1` target, NOT `wasm32-wasi`. Signal K requires WASI Preview 1. + +## Step 6: Install + +**Option 1: Symlink (Recommended for Development)** + +```bash +cd ~/.signalk/node_modules +ln -s /path/to/your/my-rust-wasm-plugin my-rust-wasm-plugin +``` + +**Option 2: Direct Copy** + +```bash +mkdir -p ~/.signalk/node_modules/my-rust-wasm-plugin +cp plugin.wasm package.json ~/.signalk/node_modules/my-rust-wasm-plugin/ +``` + +**Option 3: NPM Package Install** + +```bash +npm pack +npm install -g ./my-rust-wasm-plugin-0.1.0.tgz +``` + +## Step 7: Enable in Admin UI + +1. Navigate to **Server** → **Plugin Config** +2. Find "My Rust Plugin" +3. Click **Enable** +4. Configure settings +5. Click **Submit** + +## Rust FFI Interface Reference + +Signal K provides these FFI imports in the `env` module: + +| Function | Parameters | Description | +| ------------------------- | ---------------------------------------- | -------------------- | +| `sk_debug` | `(ptr, len)` | Log debug message | +| `sk_set_status` | `(ptr, len)` | Set plugin status | +| `sk_set_error` | `(ptr, len)` | Set error message | +| `sk_handle_message` | `(ptr, len)` | Emit delta message | +| `sk_register_put_handler` | `(ctx_ptr, ctx_len, path_ptr, path_len)` | Register PUT handler | + +> **IMPORTANT: Use Exact Function Names** +> +> You MUST use the exact function names listed above. Common mistakes: +> +> - `sk_log_debug`, `sk_log_info`, `sk_log_warn` → Use `sk_debug` for all logging +> - `sk_emit_delta` → Use `sk_handle_message` +> - `sk_udp_recv_from` → Use `sk_udp_recv` +> +> There is only one logging function (`sk_debug`). If you need log levels, prefix your message: +> +> ```rust +> debug("[INFO] Starting radar scan"); +> debug("[WARN] Connection timeout"); +> ``` + +## Required Plugin Exports + +Your plugin MUST export: + +| Export | Signature | Description | +| --------------- | ------------------------------------ | ------------------ | +| `plugin_id` | `(out_ptr, max_len) -> len` | Return plugin ID | +| `plugin_name` | `(out_ptr, max_len) -> len` | Return plugin name | +| `plugin_schema` | `(out_ptr, max_len) -> len` | Return JSON schema | +| `plugin_start` | `(config_ptr, config_len) -> status` | Start plugin | +| `plugin_stop` | `() -> status` | Stop plugin | +| `allocate` | `(size) -> ptr` | Allocate memory | +| `deallocate` | `(ptr, size)` | Free memory | + +## Optional Plugin Exports + +Your plugin MAY export: + +| Export | Signature | Description | +| ---------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `poll` | `() -> status` | Called every 1 second while plugin is running. Useful for polling hardware, sockets, or external systems. Return 0 for success, non-zero for errors. | +| `http_endpoints` | `() -> json` | Return JSON array of HTTP endpoint definitions | +| `delta_handler` | `(delta_ptr, delta_len)` | Receives Signal K deltas as JSON strings. Called for every delta emitted by the server. | + +## Additional Resources + +See the example-anchor-watch-rust plugin in `examples/wasm-plugins/example-anchor-watch-rust/` for a complete working plugin with PUT handlers. diff --git a/docs/internal/README.md b/docs/internal/README.md new file mode 100644 index 000000000..282b864d6 --- /dev/null +++ b/docs/internal/README.md @@ -0,0 +1,17 @@ +# Internal Documentation + +This folder contains internal/maintainer documentation for Signal K Server. These documents describe implementation details, architecture decisions, and technical internals that are useful for maintainers but not intended for end users or plugin developers. + +## Contents + +| Document | Description | +| -------------------------------------------- | -------------------------------------------------------------- | +| [hotplug.md](hotplug.md) | Plugin hotplug implementation (enable/disable without restart) | +| [wasm-architecture.md](wasm-architecture.md) | WASM plugin infrastructure overview | +| [wasm-asyncify.md](wasm-asyncify.md) | Asyncify implementation for async HTTP in WASM | + +## Related Documentation + +- `docs/develop/` - Public developer documentation (plugin API, REST API, etc.) +- `docs/develop/plugins/wasm/` - WASM plugin developer guide +- `docs/` - User-facing documentation (installation, configuration, etc.) diff --git a/docs/internal/wasm-architecture.md b/docs/internal/wasm-architecture.md new file mode 100644 index 000000000..a72bf0254 --- /dev/null +++ b/docs/internal/wasm-architecture.md @@ -0,0 +1,103 @@ +# WASM Plugin Architecture + +Internal documentation for Signal K Server WASM plugin infrastructure. + +## Overview + +The WASM plugin system runs alongside the existing Node.js plugin system in a hybrid mode: + +- **Node.js plugins**: Full access (unsandboxed) +- **WASM plugins**: Wasmer sandbox with VFS isolation and capability restrictions + +## Source Files + +### Core Infrastructure (`src/wasm/`) + +| File | Purpose | +| ----------------------- | -------------------------------------------------------------------- | +| `wasm-runtime.ts` | WASM runtime management (Wasmer), module loading, instance lifecycle | +| `wasm-storage.ts` | Virtual filesystem (VFS) management, per-plugin isolation | +| `wasm-serverapi.ts` | FFI bridge to ServerAPI, capability enforcement, delta handling | +| `wasm-subscriptions.ts` | Delta subscription management, pattern matching, buffering | + +### Plugin Loader (`src/wasm/loader/`) + +| File | Purpose | +| --------------------- | ----------------------------------------------------------------- | +| `types.ts` | `WasmPlugin` and `WasmPluginMetadata` interfaces | +| `plugin-registry.ts` | Plugin registration, global registry (Map), crash recovery timers | +| `plugin-lifecycle.ts` | start/stop/unload/reload, crash recovery with exponential backoff | +| `plugin-config.ts` | Configuration persistence, enable/disable at runtime | +| `plugin-routes.ts` | HTTP routes (GET/POST /config), custom plugin endpoints | +| `index.ts` | Public API entry point, re-exports all functions | + +## VFS Structure + +Each WASM plugin gets an isolated virtual filesystem: + +``` +$CONFIG_DIR/plugin-config-data/{plugin-id}/ +├── {plugin-id}.json # Server-managed config (outside VFS) +├── vfs/ # VFS root (plugin sees as "/") +│ ├── data/ # Persistent storage +│ ├── config/ # Plugin-managed config +│ └── tmp/ # Temporary files +``` + +## Plugin Identification + +WASM plugins are identified by the `wasmManifest` field in `package.json`: + +```json +{ + "name": "@scope/my-wasm-plugin", + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { + "dataRead": true, + "dataWrite": true, + "storage": "vfs-only", + "network": false + } +} +``` + +Plugin ID is derived from package name: `@scope/my-plugin` → `_scope_my-plugin` + +## Circular Dependency Resolution + +The loader modules use a forward reference pattern: + +```typescript +// In plugin-registry.ts +let startWasmPluginRef: typeof import('./plugin-lifecycle').startWasmPlugin + +export function initializeLifecycleFunctions( + startFn: typeof startWasmPluginRef +) { + startWasmPluginRef = startFn +} + +// In loader/index.ts - wire up at import time +import { initializeLifecycleFunctions } from './plugin-registry' +import { startWasmPlugin } from './plugin-lifecycle' +initializeLifecycleFunctions(startWasmPlugin) +``` + +## Dependencies + +- `@wasmer/wasi` - WASM runtime with WASI support +- `@bytecodealliance/jco` - WIT bindings generator +- `@assemblyscript/loader` - AssemblyScript runtime support +- `as-fetch` - HTTP fetch for AssemblyScript (via Asyncify) + +## Known Limitations + +- C#/.NET not supported (V8/jco incompatibility with componentize-dotnet) +- Serial ports not yet implemented +- Autopilot API not yet integrated + +## Related Documentation + +- [wasm-asyncify.md](wasm-asyncify.md) - Asyncify implementation for async HTTP +- [hotplug.md](hotplug.md) - Plugin enable/disable without restart +- `docs/develop/plugins/wasm/` - Public developer documentation diff --git a/docs/internal/wasm-asyncify.md b/docs/internal/wasm-asyncify.md new file mode 100644 index 000000000..2d7ccff4f --- /dev/null +++ b/docs/internal/wasm-asyncify.md @@ -0,0 +1,391 @@ +# Asyncify Implementation for SignalK WASM Plugins + +## Overview + +This document describes the implementation of Asyncify support in the SignalK server WASM runtime, enabling WASM plugins to perform asynchronous operations like HTTP requests using `as-fetch`. + +## What is Asyncify? + +Asyncify is a Binaryen compile-time transform that enables pausing and resuming WebAssembly execution. This allows synchronous-style code in AssemblyScript to perform async operations like HTTP requests. + +### Asyncify State Machine + +Asyncify uses a state machine with three states: + +- **State 0 (Normal)**: Normal execution +- **State 1 (Unwound/Paused)**: WASM execution is paused, waiting for async operation +- **State 2 (Rewound/Resuming)**: Async operation completed, resuming WASM execution + +## Architecture + +### Components + +1. **FetchHandler** (`as-fetch/bindings.raw.esm.js`) + - Manages Asyncify state transitions + - Handles HTTP requests via browser/Node.js fetch API + - Calls main function callback when async operations complete + +2. **WASM Runtime** (`src/wasm/wasm-runtime.ts`) + - Initializes FetchHandler with resume callback + - Detects Asyncify state after plugin_start() + - Waits for async operations to complete before returning + +3. **Plugin Lifecycle** (`src/wasm/loader/plugin-lifecycle.ts`) + - Awaits async plugin_start() function + - Handles both sync and async plugin initialization + +## Implementation Details + +### 1. FetchHandler Initialization + +In `src/wasm/wasm-runtime.ts` (lines 451-465): + +```typescript +// Store reference to the function that needs to be resumed +let asyncifyResumeFunction: (() => any) | null = null + +// Initialize as-fetch handler if network capability is enabled +if (fetchHandler && capabilities.network) { + debug(`Initializing as-fetch handler with exports`) + // The second parameter is the "main function" that gets called after async operations complete + // This function needs to re-call the WASM function to continue execution in rewind state + fetchHandler.init(rawExports, () => { + debug(`FetchHandler calling main function to resume execution`) + if (asyncifyResumeFunction) { + asyncifyResumeFunction() + } + }) +} +``` + +**Key Points:** + +- FetchHandler needs a "main function" callback to resume WASM execution +- This callback is set up BEFORE calling plugin_start to avoid race conditions +- The callback re-calls the WASM function to continue from the rewind state + +### 2. Async Plugin Start with Race Condition Prevention + +In `src/wasm/wasm-runtime.ts` (lines 504-566): + +```typescript +startFunc = async (config: string) => { + debug(`Calling plugin_start with config: ${config.substring(0, 100)}...`) + const encoder = new TextEncoder() + const configBytes = encoder.encode(config) + const configLen = configBytes.length + + const configPtr = asLoaderInstance.exports.__new(configLen, 0) + const memory = asLoaderInstance.exports.memory.buffer + const memoryView = new Uint8Array(memory) + memoryView.set(configBytes, configPtr) + + // Set up the resume function BEFORE calling plugin_start to avoid race condition + let resumePromiseResolve: (() => void) | null = null + const resumePromise = new Promise((resolve) => { + resumePromiseResolve = resolve + }) + + asyncifyResumeFunction = () => { + debug(`Re-calling plugin_start to resume from rewind state`) + const resumeResult = asLoaderInstance.exports.plugin_start( + configPtr, + configLen + ) + if (resumePromiseResolve) { + resumePromiseResolve() + } + return resumeResult + } + + // Call plugin_start - this may trigger Asyncify + let result = asLoaderInstance.exports.plugin_start(configPtr, configLen) + + // Check if Asyncify is available and the function is in unwound state + if (typeof asLoaderInstance.exports.asyncify_get_state === 'function') { + const state = asLoaderInstance.exports.asyncify_get_state() + debug(`Asyncify state after plugin_start: ${state}`) + + if (state === 1) { + debug( + `Plugin is in unwound state - waiting for async operation to complete` + ) + await resumePromise + debug(`Async operation completed, plugin execution resumed`) + } else { + asyncifyResumeFunction = null + } + } + + if (typeof asLoaderInstance.exports.__free === 'function') { + asLoaderInstance.exports.__free(configPtr) + } + + return result +} +``` + +**Key Points:** + +- **Race Condition Prevention**: Promise and callback are set up BEFORE calling plugin_start +- If set up AFTER, fast HTTP responses could complete before callback is registered +- Checks Asyncify state after initial call to detect if async operation started +- Waits for Promise to resolve before returning from start() + +### 3. Type Updates + +Updated function signatures to support async returns: + +```typescript +// Interface definition (line 124) +start: (config: string) => number | Promise + +// Plugin lifecycle (line 106) +const result = await plugin.instance.exports.start(configJson) +``` + +## Configuration File Path Fix + +### Problem + +On server restart, plugins were not loading correctly due to config file path mismatch: + +- Temporary ID from package name: `weather-plugin-example` (from `@signalk/weather-plugin-example`) +- Actual plugin ID from WASM: `weather-example` +- Config file saved by UI: `weather-example.json` +- Startup looked for: `weather-plugin-example.json` ❌ + +### Solution + +In `src/wasm/loader/plugin-registry.ts` (lines 85-110): + +```typescript +// Load WASM module temporarily just to get the plugin ID +// We need the real plugin ID to find the correct config file +const tempVfsRoot = path.join( + configPath, + 'plugin-config-data', + '.temp-' + packageName.replace(/\//g, '-') +) +if (!fs.existsSync(tempVfsRoot)) { + fs.mkdirSync(tempVfsRoot, { recursive: true }) +} + +const runtime = getWasmRuntime() +const tempInstance = await runtime.loadPlugin( + packageName, + wasmPath, + tempVfsRoot, + capabilities, + app +) + +// Extract plugin ID from WASM exports +const pluginId = tempInstance.exports.id() +const pluginName = tempInstance.exports.name() +const schemaJson = tempInstance.exports.schema() +const schema = schemaJson ? JSON.parse(schemaJson) : {} + +// Now check config using the REAL plugin ID +const storagePaths = getPluginStoragePaths(configPath, pluginId, packageName) +const savedConfig = readPluginConfig(storagePaths.configFile) +``` + +**Key Points:** + +- Always load WASM first to get real plugin ID +- Use real plugin ID to locate config file +- Reuse loaded instance for enabled plugins (no double-loading) + +## Plugin Configuration Requirements + +### 1. AssemblyScript Configuration (`asconfig.json`) + +```json +{ + "targets": { + "release": { + "outFile": "build/plugin.wasm", + "sourceMap": false, + "optimize": true, + "shrinkLevel": 2, + "converge": true, + "noAssert": true, + "runtime": "stub", + "use": "abort=" + } + }, + "options": { + "bindings": "esm", + "exportRuntime": true, + "transform": ["as-fetch/transform"] // ← CRITICAL: Enables Asyncify + } +} +``` + +**Critical Settings:** + +- `"transform": ["as-fetch/transform"]` - Enables Asyncify transform +- `"bindings": "esm"` - Generates ESM bindings for FetchHandler +- `"exportRuntime": true` - Exports Asyncify state functions + +### 2. Package Configuration (`package.json`) + +```json +{ + "name": "@signalk/weather-plugin-example", + "version": "0.1.8", + "wasmManifest": "build/plugin.wasm", + "wasmCapabilities": { + "network": true, // ← Required for as-fetch + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "serialPorts": false + }, + "dependencies": { + "as-fetch": "^2.1.4", + "signalk-assemblyscript-plugin-sdk": "^0.1.0" + } +} +``` + +**Critical Settings:** + +- `"network": true` - Grants network capability +- `"as-fetch": "^2.1.4"` - HTTP client library dependency + +## Using as-fetch in Plugins + +### Example: HTTP GET Request + +```typescript +import { fetchSync } from 'as-fetch/sync' +import { Response } from 'as-fetch/assembly' + +export function plugin_start(config: Config): i32 { + // Fetch data using synchronous-style async API + const response = fetchSync('https://api.example.com/data') + + if (response) { + const data = response.text() + // Process data... + } + + return 0 +} +``` + +**Key Points:** + +- Import from `'as-fetch/sync'` for synchronous-style API +- `fetchSync()` internally uses Asyncify to pause/resume execution +- Runtime handles all state transitions automatically + +### Example: Weather Plugin + +See `examples/wasm-plugins/example-weather-plugin/assembly/index.ts` for a complete example: + +```typescript +private fetchWeatherData(): void { + const url = + `https://api.openweathermap.org/data/2.5/weather?lat=${this.lat}&lon=${this.lon}&appid=${this.apiKey}&units=metric` + + debug('Fetching weather data from: ' + url) + + const response = fetchSync(url) + + if (!response) { + setError('Failed to fetch weather data from OpenWeatherMap') + return + } + + if (response.status !== 200) { + setError(`OpenWeatherMap API error: ${response.status.toString()}`) + return + } + + const jsonText = response.text() + const weatherData = JSON.parse(jsonText) + + // Create and emit deltas + const delta = createEmptyDelta(this.pluginId) + + const tempUpdate = new Update() + tempUpdate.path = 'environment.outside.temperature' + tempUpdate.value = JSON.stringify(weatherData.main.temp + 273.15) + delta.updates[0].values.push(tempUpdate) + + emit(delta) +} +``` + +## Debugging + +### Enable Debug Logging + +Set `DEBUG=signalk:wasm:*` environment variable: + +```bash +DEBUG=signalk:wasm:* npm start +``` + +### Key Log Messages + +``` +signalk:wasm:runtime Initializing as-fetch handler with exports +signalk:wasm:runtime Calling plugin_start with config: {... +signalk:wasm:runtime Asyncify state after plugin_start: 1 +signalk:wasm:runtime Plugin is in unwound state - waiting for async operation to complete +signalk:wasm:runtime FetchHandler calling main function to resume execution +signalk:wasm:runtime Re-calling plugin_start to resume from rewind state +signalk:wasm:runtime Async operation completed, plugin execution resumed +``` + +### Common Issues + +**Issue**: Plugin doesn't fetch data on server restart + +- **Cause**: Config file not found due to plugin ID mismatch +- **Solution**: Fixed in plugin-registry.ts - loads WASM first to get real ID + +**Issue**: FetchHandler callback undefined + +- **Cause**: Race condition - callback set after async operation completes +- **Solution**: Set up Promise and callback BEFORE calling plugin_start + +**Issue**: Asyncify transform not working + +- **Cause**: Missing `"transform": ["as-fetch/transform"]` in asconfig.json +- **Solution**: Add transform to options in asconfig.json + +## Testing + +### Test on Enable/Disable + +1. Open Plugin Config UI +2. Disable plugin → Enable plugin +3. Check logs for successful fetch +4. Verify data appears in Signal K paths + +### Test on Server Restart + +1. Enable plugin in Plugin Config UI +2. Restart SignalK server +3. Check logs for: + - Config file loaded correctly + - Plugin started automatically + - HTTP request made successfully + - Data emitted to paths + +## References + +- Binaryen Asyncify: https://github.com/WebAssembly/binaryen/blob/main/src/passes/Asyncify.cpp +- as-fetch Library: https://github.com/rockmor/as-fetch +- AssemblyScript Documentation: https://www.assemblyscript.org/ + +## Version History + +- **v0.1.8** - Fixed config file path mismatch and race condition +- **v0.1.7** - Added race condition prevention in callback setup +- **v0.1.6** - Initial Asyncify support implementation diff --git a/eslint.config.js b/eslint.config.js index 8437dadbe..76fe16a54 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,7 +7,19 @@ const react = require('eslint-plugin-react') const chai = require('eslint-plugin-chai-friendly') module.exports = defineConfig([ - globalIgnores(['**/public', '**/dist']), + globalIgnores([ + '**/public', + '**/dist', + // WASM plugin examples - AssemblyScript has different semantics + 'examples/wasm-plugins/**/assembly/**', + // AssemblyScript SDK - decorators and types not compatible with ESLint + 'packages/assemblyscript-plugin-sdk/assembly/**', + // Auto-generated WASM bindings (created by AssemblyScript compiler) + 'examples/wasm-plugins/**/build/**', + 'examples/wasm-plugins/**/plugin.js', + 'examples/wasm-plugins/**/plugin.d.ts', + 'packages/assemblyscript-plugin-sdk/build/**' + ]), // TypeScript options { diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/.gitignore b/examples/wasm-plugins/example-anchor-watch-dotnet/.gitignore new file mode 100644 index 000000000..e8e2ee81c --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/.gitignore @@ -0,0 +1,24 @@ +# Build outputs +bin/ +obj/ +*.wasm +*.dll +*.pdb + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Rider +.idea/ + +# macOS +.DS_Store diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/AnchorWatch.csproj b/examples/wasm-plugins/example-anchor-watch-dotnet/AnchorWatch.csproj new file mode 100644 index 000000000..f7f1c994b --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/AnchorWatch.csproj @@ -0,0 +1,50 @@ + + + + net10.0 + + wasi-wasm + false + true + true + true + + Library + true + enable + + true + + false + + false + + + + + + + + + + + + + + + + + + + + + + + $(IntermediateOutputPath)wit_bindgen\SignalkPlugin.cs + + + + + + diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/PluginImpl.cs b/examples/wasm-plugins/example-anchor-watch-dotnet/PluginImpl.cs new file mode 100644 index 000000000..3b4edfb08 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/PluginImpl.cs @@ -0,0 +1,221 @@ +/** + * Anchor Watch - C# WASM Plugin for Signal K + * + * This implementation uses WIT bindings generated by componentize-dotnet. + * The plugin interface is defined in wit/signalk-plugin.wit + */ + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using SignalkPluginWorld.wit.imports.signalk.plugin.v1_0_0; + +namespace SignalkPluginWorld.wit.exports.signalk.plugin.v1_0_0; + +/// +/// Implementation of the Signal K plugin interface defined in WIT +/// +public class PluginImpl : IPlugin +{ + private static AnchorState anchorState = new AnchorState(); + private static bool debugEnabled = false; + + private static void LogDebug(string message) + { + if (debugEnabled) + { + SignalkApiInterop.SkDebug($"[anchor-watch-dotnet] {message}"); + } + } + + /// + /// Returns the unique plugin identifier + /// + public static string PluginId() + { + return "anchor-watch-dotnet"; + } + + /// + /// Returns the human-readable plugin name + /// + public static string PluginName() + { + return "Anchor Watch (.NET)"; + } + + /// + /// Returns the JSON schema for plugin configuration + /// + public static string PluginSchema() + { + return @"{ + ""type"": ""object"", + ""properties"": { + ""maxRadius"": { + ""type"": ""number"", + ""title"": ""Default Drag Alarm Radius (meters)"", + ""default"": 50 + }, + ""alarmEnabled"": { + ""type"": ""boolean"", + ""title"": ""Enable Alarm"", + ""default"": false + }, + ""enableDebug"": { + ""type"": ""boolean"", + ""title"": ""Enable Debug Logging"", + ""default"": false + } + } +}"; + } + + /// + /// Called when the plugin is started with configuration JSON + /// + public static int PluginStart(string config) + { + try + { + // Parse configuration + try + { + var configObj = JsonSerializer.Deserialize(config, PluginJsonContext.Default.DictionaryStringJsonElement); + if (configObj != null) + { + if (configObj.ContainsKey("enableDebug")) + { + debugEnabled = configObj["enableDebug"].GetBoolean(); + } + + var configuration = configObj.ContainsKey("configuration") + ? JsonSerializer.Deserialize(configObj["configuration"].GetRawText(), PluginJsonContext.Default.DictionaryStringJsonElement) + : null; + + if (configuration != null) + { + if (configuration.ContainsKey("maxRadius")) + { + anchorState.MaxRadius = configuration["maxRadius"].GetDouble(); + } + if (configuration.ContainsKey("alarmEnabled")) + { + anchorState.AlarmEnabled = configuration["alarmEnabled"].GetBoolean(); + } + } + } + } + catch (Exception ex) + { + LogDebug($"Configuration parse error: {ex.Message}"); + } + + LogDebug("========================================"); + LogDebug("Anchor Watch plugin starting..."); + LogDebug($"Configuration: {config}"); + LogDebug($"Debug logging: {(debugEnabled ? "ENABLED" : "DISABLED")}"); + LogDebug($"Max radius: {anchorState.MaxRadius}m"); + LogDebug($"Alarm enabled: {anchorState.AlarmEnabled}"); + + // Register PUT handlers + LogDebug("Registering PUT handlers..."); + + if (SignalkApiInterop.SkRegisterPutHandler("vessels.self", "navigation.anchor.position") == 1) + { + LogDebug("Registered: navigation.anchor.position"); + } + + if (SignalkApiInterop.SkRegisterPutHandler("vessels.self", "navigation.anchor.maxRadius") == 1) + { + LogDebug("Registered: navigation.anchor.maxRadius"); + } + + if (SignalkApiInterop.SkRegisterPutHandler("vessels.self", "navigation.anchor.alarmState") == 1) + { + LogDebug("Registered: navigation.anchor.alarmState"); + } + + SignalkApiInterop.SkSetStatus("Started - waiting for anchor drop"); + + LogDebug("========================================"); + LogDebug("Anchor Watch plugin started successfully!"); + LogDebug("========================================"); + + return 0; // Success + } + catch (Exception ex) + { + SignalkApiInterop.SkSetError($"Start failed: {ex.Message}"); + return 1; // Error + } + } + + /// + /// Called when the plugin is stopped + /// + public static int PluginStop() + { + LogDebug("Anchor Watch plugin stopping..."); + SignalkApiInterop.SkSetStatus("Stopped"); + return 0; + } +} + +// Data models +public class Position +{ + [JsonPropertyName("latitude")] + public double Latitude { get; set; } + + [JsonPropertyName("longitude")] + public double Longitude { get; set; } +} + +public class AnchorState +{ + [JsonPropertyName("position")] + public Position? Position { get; set; } + + [JsonPropertyName("maxRadius")] + public double MaxRadius { get; set; } = 50.0; + + [JsonPropertyName("alarmEnabled")] + public bool AlarmEnabled { get; set; } = false; +} + +public class PutRequest +{ + [JsonPropertyName("context")] + public string Context { get; set; } = ""; + + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("value")] + public JsonElement Value { get; set; } +} + +public class PutResponse +{ + [JsonPropertyName("state")] + public string State { get; set; } = "COMPLETED"; + + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } = 200; + + [JsonPropertyName("message")] + public string? Message { get; set; } +} + +// JSON source generator context for AOT/trimming compatibility +[JsonSourceGenerationOptions(WriteIndented = false)] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Position))] +[JsonSerializable(typeof(AnchorState))] +[JsonSerializable(typeof(PutRequest))] +[JsonSerializable(typeof(PutResponse))] +internal partial class PluginJsonContext : JsonSerializerContext +{ +} diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/Program.cs b/examples/wasm-plugins/example-anchor-watch-dotnet/Program.cs new file mode 100644 index 000000000..022483e26 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/Program.cs @@ -0,0 +1,591 @@ +/** + * Anchor Watch - C# WASM Plugin for Signal K + * + * Demonstrates: + * - PUT handler registration and implementation + * - VFS storage for persistent state + * - Delta emission for notifications + * - C# / .NET WASM development + * + * This plugin monitors vessel position and alerts when the vessel drags anchor. + */ + +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SignalK.AnchorWatch +{ + // FFI imports from Signal K server + internal static class SignalKApi + { + [DllImport("env", EntryPoint = "sk_debug")] + public static extern void Debug(IntPtr messagePtr, int messageLen); + + [DllImport("env", EntryPoint = "sk_set_status")] + public static extern void SetStatus(IntPtr messagePtr, int messageLen); + + [DllImport("env", EntryPoint = "sk_set_error")] + public static extern void SetError(IntPtr messagePtr, int messageLen); + + [DllImport("env", EntryPoint = "sk_handle_message")] + public static extern void HandleMessage(IntPtr deltaPtr, int deltaLen); + + [DllImport("env", EntryPoint = "sk_register_put_handler")] + public static extern int RegisterPutHandler(IntPtr contextPtr, int contextLen, IntPtr pathPtr, int pathLen); + + [DllImport("env", EntryPoint = "sk_get_self_path")] + public static extern int GetSelfPath(IntPtr pathPtr, int pathLen, IntPtr outPtr, int outMaxLen); + + // Helper methods + public static void Log(string message) + { + var bytes = Encoding.UTF8.GetBytes(message); + unsafe + { + fixed (byte* ptr = bytes) + { + Debug((IntPtr)ptr, bytes.Length); + } + } + } + + public static void Status(string message) + { + var bytes = Encoding.UTF8.GetBytes(message); + unsafe + { + fixed (byte* ptr = bytes) + { + SetStatus((IntPtr)ptr, bytes.Length); + } + } + } + + public static void Error(string message) + { + var bytes = Encoding.UTF8.GetBytes(message); + unsafe + { + fixed (byte* ptr = bytes) + { + SetError((IntPtr)ptr, bytes.Length); + } + } + } + + public static void EmitDelta(string deltaJson) + { + var bytes = Encoding.UTF8.GetBytes(deltaJson); + unsafe + { + fixed (byte* ptr = bytes) + { + HandleMessage((IntPtr)ptr, bytes.Length); + } + } + } + + public static bool RegisterPut(string context, string path) + { + var contextBytes = Encoding.UTF8.GetBytes(context); + var pathBytes = Encoding.UTF8.GetBytes(path); + unsafe + { + fixed (byte* contextPtr = contextBytes) + fixed (byte* pathPtr = pathBytes) + { + int result = RegisterPutHandler((IntPtr)contextPtr, contextBytes.Length, (IntPtr)pathPtr, pathBytes.Length); + return result == 1; + } + } + } + + public static string? GetPath(string path) + { + var pathBytes = Encoding.UTF8.GetBytes(path); + var buffer = new byte[4096]; + unsafe + { + fixed (byte* pathPtr = pathBytes) + fixed (byte* outPtr = buffer) + { + int len = GetSelfPath((IntPtr)pathPtr, pathBytes.Length, (IntPtr)outPtr, buffer.Length); + if (len > 0) + { + return Encoding.UTF8.GetString(buffer, 0, len); + } + } + } + return null; + } + } + + // Data models + public class Position + { + [JsonPropertyName("latitude")] + public double Latitude { get; set; } + + [JsonPropertyName("longitude")] + public double Longitude { get; set; } + } + + public class AnchorState + { + [JsonPropertyName("position")] + public Position? Position { get; set; } + + [JsonPropertyName("maxRadius")] + public double MaxRadius { get; set; } = 50.0; // meters + + [JsonPropertyName("alarmEnabled")] + public bool AlarmEnabled { get; set; } = false; + } + + public class PutRequest + { + [JsonPropertyName("context")] + public string Context { get; set; } = ""; + + [JsonPropertyName("path")] + public string Path { get; set; } = ""; + + [JsonPropertyName("value")] + public JsonElement Value { get; set; } + } + + public class PutResponse + { + [JsonPropertyName("state")] + public string State { get; set; } = "COMPLETED"; + + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } = 200; + + [JsonPropertyName("message")] + public string? Message { get; set; } + } + + // JSON source generator context for AOT/trimming compatibility + [JsonSourceGenerationOptions(WriteIndented = false)] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Position))] + [JsonSerializable(typeof(AnchorState))] + [JsonSerializable(typeof(PutRequest))] + [JsonSerializable(typeof(PutResponse))] + internal partial class SourceGenerationContext : JsonSerializerContext + { + } + + // Main plugin class + public static class AnchorWatchPlugin + { + private static AnchorState anchorState = new AnchorState(); + private static bool debugEnabled = false; + + private static void LogDebug(string message) + { + if (debugEnabled) + { + SignalKApi.Log($"[anchor-watch-dotnet] {message}"); + } + } + + // Plugin exports - called by Signal K server + + [UnmanagedCallersOnly(EntryPoint = "plugin_id")] + public static IntPtr GetId() + { + return MarshalString("anchor-watch-dotnet"); + } + + [UnmanagedCallersOnly(EntryPoint = "plugin_name")] + public static IntPtr GetName() + { + return MarshalString("Anchor Watch (.NET)"); + } + + [UnmanagedCallersOnly(EntryPoint = "plugin_schema")] + public static IntPtr GetSchema() + { + var schema = @"{ + ""type"": ""object"", + ""properties"": { + ""maxRadius"": { + ""type"": ""number"", + ""title"": ""Default Drag Alarm Radius (meters)"", + ""default"": 50 + }, + ""alarmEnabled"": { + ""type"": ""boolean"", + ""title"": ""Enable Alarm"", + ""default"": false + }, + ""enableDebug"": { + ""type"": ""boolean"", + ""title"": ""Enable Debug Logging"", + ""default"": false + } + } +}"; + return MarshalString(schema); + } + + [UnmanagedCallersOnly(EntryPoint = "plugin_start")] + public static int Start(IntPtr configPtr, int configLen) + { + try + { + var configJson = ReadString(configPtr, configLen); + LogDebug("========================================"); + LogDebug("Anchor Watch plugin starting..."); + LogDebug($"Configuration: {configJson}"); + + // Parse configuration + try + { + var config = JsonSerializer.Deserialize(configJson, SourceGenerationContext.Default.DictionaryStringJsonElement); + if (config != null) + { + if (config.ContainsKey("enableDebug")) + { + debugEnabled = config["enableDebug"].GetBoolean(); + } + + var configuration = config.ContainsKey("configuration") + ? JsonSerializer.Deserialize(config["configuration"].GetRawText(), SourceGenerationContext.Default.DictionaryStringJsonElement) + : null; + + if (configuration != null) + { + if (configuration.ContainsKey("maxRadius")) + { + anchorState.MaxRadius = configuration["maxRadius"].GetDouble(); + } + if (configuration.ContainsKey("alarmEnabled")) + { + anchorState.AlarmEnabled = configuration["alarmEnabled"].GetBoolean(); + } + } + } + } + catch (Exception ex) + { + LogDebug($"Configuration parse error: {ex.Message}"); + } + + LogDebug($"Debug logging: {(debugEnabled ? "ENABLED" : "DISABLED")}"); + LogDebug($"Max radius: {anchorState.MaxRadius}m"); + LogDebug($"Alarm enabled: {anchorState.AlarmEnabled}"); + + // Register PUT handlers + LogDebug("Registering PUT handlers..."); + + if (SignalKApi.RegisterPut("vessels.self", "navigation.anchor.position")) + { + LogDebug("✓ Registered: navigation.anchor.position"); + } + + if (SignalKApi.RegisterPut("vessels.self", "navigation.anchor.maxRadius")) + { + LogDebug("✓ Registered: navigation.anchor.maxRadius"); + } + + if (SignalKApi.RegisterPut("vessels.self", "navigation.anchor.alarmState")) + { + LogDebug("✓ Registered: navigation.anchor.alarmState"); + } + + SignalKApi.Status("Started - waiting for anchor drop"); + LogDebug("========================================"); + LogDebug("Anchor Watch plugin started successfully!"); + LogDebug("========================================"); + + return 0; // Success + } + catch (Exception ex) + { + SignalKApi.Error($"Start failed: {ex.Message}"); + return 1; // Error + } + } + + [UnmanagedCallersOnly(EntryPoint = "plugin_stop")] + public static int Stop() + { + LogDebug("Anchor Watch plugin stopping..."); + SignalKApi.Status("Stopped"); + return 0; + } + + // PUT Handlers + + [UnmanagedCallersOnly(EntryPoint = "handle_put_vessels_self_navigation_anchor_position")] + public static IntPtr HandleSetAnchorPosition(IntPtr requestPtr, int requestLen) + { + try + { + var requestJson = ReadString(requestPtr, requestLen); + LogDebug($"PUT handler called: set anchor position"); + LogDebug($"Request: {requestJson}"); + + var request = JsonSerializer.Deserialize(requestJson, SourceGenerationContext.Default.PutRequest); + if (request == null) + { + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 400, + Message = "Invalid request" + }); + } + + var position = JsonSerializer.Deserialize(request.Value.GetRawText(), SourceGenerationContext.Default.Position); + if (position == null) + { + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 400, + Message = "Invalid position format" + }); + } + + // Set anchor position + anchorState.Position = position; + LogDebug($"Anchor dropped at: {position.Latitude:F6}, {position.Longitude:F6}"); + + // Emit delta to update data model + var delta = $@"{{ + ""context"": ""vessels.self"", + ""updates"": [{{ + ""source"": {{ + ""label"": ""anchor-watch-dotnet"", + ""type"": ""plugin"" + }}, + ""timestamp"": ""{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}"", + ""values"": [{{ + ""path"": ""navigation.anchor.position"", + ""value"": {{ + ""latitude"": {position.Latitude}, + ""longitude"": {position.Longitude} + }} + }}] + }}] +}}"; + SignalKApi.EmitDelta(delta); + + SignalKApi.Status($"Anchor set at {position.Latitude:F6}, {position.Longitude:F6}"); + + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 200, + Message = "Anchor position set successfully" + }); + } + catch (Exception ex) + { + LogDebug($"Error handling PUT: {ex.Message}"); + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 500, + Message = $"Error: {ex.Message}" + }); + } + } + + [UnmanagedCallersOnly(EntryPoint = "handle_put_vessels_self_navigation_anchor_maxRadius")] + public static IntPtr HandleSetMaxRadius(IntPtr requestPtr, int requestLen) + { + try + { + var requestJson = ReadString(requestPtr, requestLen); + LogDebug($"PUT handler called: set max radius"); + + var request = JsonSerializer.Deserialize(requestJson, SourceGenerationContext.Default.PutRequest); + if (request == null || request.Value.ValueKind != JsonValueKind.Number) + { + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 400, + Message = "Invalid radius value" + }); + } + + var radius = request.Value.GetDouble(); + if (radius <= 0 || radius > 1000) + { + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 400, + Message = "Radius must be between 0 and 1000 meters" + }); + } + + anchorState.MaxRadius = radius; + LogDebug($"Max radius set to: {radius}m"); + + // Emit delta + var delta = $@"{{ + ""context"": ""vessels.self"", + ""updates"": [{{ + ""source"": {{ + ""label"": ""anchor-watch-dotnet"", + ""type"": ""plugin"" + }}, + ""timestamp"": ""{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}"", + ""values"": [{{ + ""path"": ""navigation.anchor.maxRadius"", + ""value"": {radius} + }}] + }}] +}}"; + SignalKApi.EmitDelta(delta); + + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 200, + Message = $"Drag alarm radius set to {radius}m" + }); + } + catch (Exception ex) + { + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 500, + Message = $"Error: {ex.Message}" + }); + } + } + + [UnmanagedCallersOnly(EntryPoint = "handle_put_vessels_self_navigation_anchor_alarmState")] + public static IntPtr HandleSetAlarmState(IntPtr requestPtr, int requestLen) + { + try + { + var requestJson = ReadString(requestPtr, requestLen); + LogDebug($"PUT handler called: set alarm state"); + + var request = JsonSerializer.Deserialize(requestJson, SourceGenerationContext.Default.PutRequest); + if (request == null) + { + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 400, + Message = "Invalid request" + }); + } + + bool enabled; + if (request.Value.ValueKind == JsonValueKind.True) + { + enabled = true; + } + else if (request.Value.ValueKind == JsonValueKind.False) + { + enabled = false; + } + else if (request.Value.ValueKind == JsonValueKind.String) + { + var str = request.Value.GetString(); + enabled = str == "on" || str == "enabled" || str == "true"; + } + else + { + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 400, + Message = "Invalid alarm state value" + }); + } + + anchorState.AlarmEnabled = enabled; + LogDebug($"Alarm state set to: {(enabled ? "ENABLED" : "DISABLED")}"); + + SignalKApi.Status(enabled ? "Alarm enabled" : "Alarm disabled"); + + // Emit notification delta + var delta = $@"{{ + ""context"": ""vessels.self"", + ""updates"": [{{ + ""source"": {{ + ""label"": ""anchor-watch-dotnet"", + ""type"": ""plugin"" + }}, + ""timestamp"": ""{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}"", + ""values"": [{{ + ""path"": ""notifications.anchor.drag"", + ""value"": {{ + ""state"": ""{(enabled ? "normal" : "cancel")}"", + ""method"": [], + ""message"": ""Anchor watch alarm {(enabled ? "enabled" : "disabled")}"" + }} + }}] + }}] +}}"; + SignalKApi.EmitDelta(delta); + + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 200, + Message = $"Alarm {(enabled ? "enabled" : "disabled")}" + }); + } + catch (Exception ex) + { + return MarshalJson(new PutResponse + { + State = "COMPLETED", + StatusCode = 500, + Message = $"Error: {ex.Message}" + }); + } + } + + // Helper methods for string marshaling + + private static IntPtr MarshalString(string str) + { + var bytes = Encoding.UTF8.GetBytes(str + "\0"); + IntPtr ptr = Marshal.AllocHGlobal(bytes.Length); + Marshal.Copy(bytes, 0, ptr, bytes.Length); + return ptr; + } + + private static IntPtr MarshalJson(PutResponse obj) + { + var json = JsonSerializer.Serialize(obj, SourceGenerationContext.Default.PutResponse); + return MarshalString(json); + } + + private static string ReadString(IntPtr ptr, int len) + { + var bytes = new byte[len]; + Marshal.Copy(ptr, bytes, 0, len); + return Encoding.UTF8.GetString(bytes); + } + } + + // Entry point + public class Program + { + public static void Main() + { + // WASI entry point - not used for plugins + SignalKApi.Log("anchor-watch-dotnet loaded"); + } + } +} diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/README.md b/examples/wasm-plugins/example-anchor-watch-dotnet/README.md new file mode 100644 index 000000000..ba0fa5ecb --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/README.md @@ -0,0 +1,700 @@ +# Example Anchor Watch - C# WASM Plugin + +> **🚧 NOT WORKING - Waiting for Better Tooling** +> +> This example demonstrates the **intended approach** for building .NET WASM plugins using +> componentize-dotnet and the WASI Component Model. However, **it does not currently work** +> due to fundamental runtime incompatibilities. +> +> **Status:** ❌ **Non-functional** - documented for future reference +> +> **For working examples, see:** +> +> - `../example-hello-assemblyscript/` - AssemblyScript (recommended, fully working) +> - `../example-anchor-watch-rust/` - Rust (fully working) + +--- + +## Why This Doesn't Work (Yet) + +### The Problem + +The .NET WASM toolchain (`componentize-dotnet`) produces WASI Component Model output that +**cannot run in Node.js/V8**. This is a fundamental incompatibility, not a configuration issue. + +### Technical Details + +1. **componentize-dotnet only supports Wasmtime and WAMR** ([source](https://github.com/bytecodealliance/componentize-dotnet)) + - The README explicitly states: "works with Wasmtime and WAMR" + - Node.js/V8 is NOT a supported runtime + - jco transpilation does NOT bridge this gap + +2. **Function table initialization fails in V8** + - .NET NativeAOT uses indirect call tables that initialize correctly in Wasmtime + - In V8 (via jco transpilation), these tables contain null entries + - Error: `RuntimeError: null function or function signature mismatch` + +3. **Attempted workarounds that did NOT work:** + - Calling `_initialize()` manually - fails silently + - Calling `InitializeModules()` - crashes (already called by `_initialize`) + - Removing `[ThreadStatic]` attribute - fixed build but not runtime + - Various jco flags (`--tla-compat`, `--instantiation sync`) + +### What Would Be Needed + +1. **Native Wasmtime in Node.js** - A proper `@bytecodealliance/wasmtime` npm package + that embeds Wasmtime directly (does not exist as of Dec 2024) + +2. **Improved jco support** - jco would need to properly handle .NET NativeAOT's + function table initialization + +3. **Alternative .NET toolchain** - A different compilation path that produces + V8-compatible WASM + +### Recommendation + +**Wait for better tooling.** Both componentize-dotnet and jco are experimental projects +under active development. The WASI Component Model ecosystem is rapidly evolving. + +For now, use **AssemblyScript** for Signal K WASM plugins - it works reliably and +produces much smaller binaries (3-10 KB vs 20+ MB). + +For technical details, see the upstream issue: https://github.com/bytecodealliance/componentize-dotnet/issues/103 + +--- + +## Reference Documentation + +The information below documents how this plugin **would** work once the tooling matures. +It is preserved for future reference. + +--- + +**A comprehensive example demonstrating PUT handlers and C#/.NET WASM development for Signal K** + +This plugin showcases how to build Signal K WASM plugins using C# and .NET, with a focus on implementing PUT handlers for vessel control and monitoring. + +## Features + +✅ **PUT Handler Implementation** - Handle PUT requests to control plugin state +✅ **C# / .NET 10** - Modern C# development with WASI support +✅ **Anchor Watch Logic** - Monitor vessel position and detect anchor drag +✅ **State Management** - Persist anchor position and alarm settings +✅ **Delta Emission** - Update Signal K data model with anchor status +✅ **Type-Safe API** - Strongly-typed C# API for Signal K integration + +## What is Anchor Watch? + +Anchor watch monitors your vessel's position after dropping anchor. If the vessel drifts beyond a specified radius (indicating the anchor is dragging), an alarm is triggered. + +### PUT Handlers + +This plugin registers three PUT handlers that allow external clients to control the anchor watch: + +1. **`navigation.anchor.position`** - Set the anchor drop position +2. **`navigation.anchor.maxRadius`** - Set the drag alarm radius (meters) +3. **`navigation.anchor.alarmState`** - Enable/disable the drag alarm + +## Prerequisites + +### Required + +- **.NET 10 SDK** + Download from: https://dotnet.microsoft.com/download/dotnet/10.0 + +- **WASI SDK 25.0** (required for native WASM compilation) + Download from: https://github.com/WebAssembly/wasi-sdk/releases/tag/wasi-sdk-25 + + **Windows:** + + ```powershell + # Download and extract wasi-sdk-25 + Invoke-WebRequest -Uri "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-x86_64-windows.tar.gz" -OutFile wasi-sdk-25.tar.gz + tar -xzf wasi-sdk-25.tar.gz + + # Set environment variable (required before building) + $env:WASI_SDK_PATH = "C:\path\to\wasi-sdk-25.0-x86_64-windows" + ``` + + **Linux:** + + ```bash + # Download and extract wasi-sdk-25 + wget https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-x86_64-linux.tar.gz + tar -xzf wasi-sdk-25.0-x86_64-linux.tar.gz + + # Set environment variable (add to ~/.bashrc for persistence) + export WASI_SDK_PATH=/path/to/wasi-sdk-25.0-x86_64-linux + ``` + + **macOS:** + + ```bash + # Download and extract wasi-sdk-25 + wget https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-arm64-macos.tar.gz + tar -xzf wasi-sdk-25.0-arm64-macos.tar.gz + + # Set environment variable (add to ~/.zshrc for persistence) + export WASI_SDK_PATH=/path/to/wasi-sdk-25.0-arm64-macos + ``` + + > **Note:** .NET 10 specifically requires wasi-sdk version 25.0. Other versions will not work. + +- **Signal K Server 3.0+** with WASM support + +### Verify Installation + +```bash +dotnet --version # Should be 10.0.x or later +echo $WASI_SDK_PATH # Should point to wasi-sdk-25 folder +``` + +## Quick Start + +### 1. Build the Plugin + +```bash +cd examples/wasm-plugins/example-anchor-watch-dotnet +dotnet publish -c Release +``` + +This compiles the C# code to a native WASM binary. + +### 2. Copy WASM Binary + +After building, copy the generated WASM file: + +```bash +# The build output is in bin/Release/net10.0/wasi-wasm/ +cp bin/Release/net10.0/wasi-wasm/AnchorWatch.wasm plugin.wasm +``` + +**Windows (PowerShell):** + +```powershell +copy bin\Release\net10.0\wasi-wasm\AnchorWatch.wasm plugin.wasm +``` + +### 3. Install to Signal K + +**Option A: Direct Copy (Development)** + +```bash +mkdir -p ~/.signalk/node_modules/@signalk/example-anchor-watch-dotnet +cp plugin.wasm package.json ~/.signalk/node_modules/@signalk/example-anchor-watch-dotnet/ +``` + +**Option B: NPM Package (Production)** + +```bash +npm pack +npm install -g signalk-example-anchor-watch-dotnet-0.1.0.tgz +``` + +### 4. Enable in Admin UI + +1. Navigate to **Server → Plugin Config** +2. Find "Anchor Watch (.NET)" +3. Click **Enable** +4. Configure settings (max radius, alarm state) +5. Click **Submit** + +### 5. Test PUT Handlers + +Test the PUT handlers using curl or the Signal K REST API: + +#### Set Anchor Position + +```bash +curl -X PUT http://localhost:3000/signalk/v1/api/vessels/self/navigation/anchor/position \ + -H "Content-Type: application/json" \ + -d '{"value": {"latitude": 60.1234, "longitude": 24.5678}}' +``` + +#### Set Drag Alarm Radius + +```bash +curl -X PUT http://localhost:3000/signalk/v1/api/vessels/self/navigation/anchor/maxRadius \ + -H "Content-Type: application/json" \ + -d '{"value": 75}' +``` + +#### Enable Alarm + +```bash +curl -X PUT http://localhost:3000/signalk/v1/api/vessels/self/navigation/anchor/alarmState \ + -H "Content-Type: application/json" \ + -d '{"value": true}' +``` + +## Project Structure + +``` +example-anchor-watch-dotnet/ +├── Program.cs # Main plugin implementation +├── AnchorWatch.csproj # .NET project file +├── package.json # Signal K plugin manifest +├── plugin.wasm # Compiled WASM binary (generated) +└── README.md # This file +``` + +## Understanding the Code + +### Plugin Lifecycle + +The plugin implements the standard Signal K WASM plugin interface: + +```csharp +[UnmanagedCallersOnly(EntryPoint = "plugin_id")] +public static IntPtr GetId() { ... } + +[UnmanagedCallersOnly(EntryPoint = "plugin_name")] +public static IntPtr GetName() { ... } + +[UnmanagedCallersOnly(EntryPoint = "plugin_schema")] +public static IntPtr GetSchema() { ... } + +[UnmanagedCallersOnly(EntryPoint = "plugin_start")] +public static int Start(IntPtr configPtr, int configLen) { ... } + +[UnmanagedCallersOnly(EntryPoint = "plugin_stop")] +public static int Stop() { ... } +``` + +### FFI Bridge + +The plugin communicates with the Signal K server through FFI (Foreign Function Interface): + +```csharp +[DllImport("env", EntryPoint = "sk_debug")] +public static extern void Debug(IntPtr messagePtr, int messageLen); + +[DllImport("env", EntryPoint = "sk_register_put_handler")] +public static extern int RegisterPutHandler(IntPtr contextPtr, int contextLen, IntPtr pathPtr, int pathLen); +``` + +### PUT Handler Registration + +During `plugin_start()`, the plugin registers its PUT handlers: + +```csharp +SignalKApi.RegisterPut("vessels.self", "navigation.anchor.position"); +SignalKApi.RegisterPut("vessels.self", "navigation.anchor.maxRadius"); +SignalKApi.RegisterPut("vessels.self", "navigation.anchor.alarmState"); +``` + +### PUT Handler Implementation + +Each PUT handler is exported with a specific naming convention: + +```csharp +[UnmanagedCallersOnly(EntryPoint = "handle_put_vessels_self_navigation_anchor_position")] +public static IntPtr HandleSetAnchorPosition(IntPtr requestPtr, int requestLen) +{ + // 1. Parse request JSON + var request = JsonSerializer.Deserialize(requestJson); + + // 2. Validate and process + var position = JsonSerializer.Deserialize(request.Value.GetRawText()); + anchorState.Position = position; + + // 3. Emit delta to update data model + SignalKApi.EmitDelta(deltaJson); + + // 4. Return response + return MarshalJson(new PutResponse { + State = "COMPLETED", + StatusCode = 200, + Message = "Success" + }); +} +``` + +**Handler Naming Convention:** + +- Format: `handle_put_{context}_{path}` with dots replaced by underscores +- Example: `handle_put_vessels_self_navigation_anchor_position` + +### Data Models + +The plugin uses strongly-typed C# classes with JSON serialization: + +```csharp +public class Position +{ + [JsonPropertyName("latitude")] + public double Latitude { get; set; } + + [JsonPropertyName("longitude")] + public double Longitude { get; set; } +} + +public class PutRequest +{ + [JsonPropertyName("context")] + public string Context { get; set; } + + [JsonPropertyName("path")] + public string Path { get; set; } + + [JsonPropertyName("value")] + public JsonElement Value { get; set; } +} + +public class PutResponse +{ + [JsonPropertyName("state")] + public string State { get; set; } = "COMPLETED"; + + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } = 200; + + [JsonPropertyName("message")] + public string? Message { get; set; } +} +``` + +## Configuration + +Configure the plugin via the Signal K Admin UI under **Server → Plugin Config**. Configuration options are documented in the plugin's schema. + +## PUT Request/Response Format + +### Request Format + +All PUT requests follow this structure: + +```json +{ + "context": "vessels.self", + "path": "navigation.anchor.position", + "value": { + "latitude": 60.1234, + "longitude": 24.5678 + } +} +``` + +### Response Format + +PUT handlers return a response indicating success or failure: + +```json +{ + "state": "COMPLETED", + "statusCode": 200, + "message": "Anchor position set successfully" +} +``` + +**Response States:** + +- `COMPLETED` - Request completed (success or error) +- `PENDING` - Request accepted but still processing (not used in this plugin) + +**Status Codes:** + +- `200` - Success +- `400` - Bad request (invalid input) +- `500` - Server error (handler exception) + +## Signal K Data Model Updates + +The plugin updates the following paths in the Signal K data model: + +### `navigation.anchor.position` + +```json +{ + "path": "navigation.anchor.position", + "value": { + "latitude": 60.1234, + "longitude": 24.5678 + } +} +``` + +### `navigation.anchor.maxRadius` + +```json +{ + "path": "navigation.anchor.maxRadius", + "value": 75 +} +``` + +### `notifications.anchor.drag` + +```json +{ + "path": "notifications.anchor.drag", + "value": { + "state": "normal", + "method": [], + "message": "Anchor watch alarm enabled" + } +} +``` + +## Development + +### Build for Release + +```bash +dotnet publish -c Release +cp bin/Release/net10.0/wasi-wasm/AnchorWatch.wasm plugin.wasm +``` + +### Build for Debug + +```bash +dotnet publish -c Debug +cp bin/Debug/net10.0/wasi-wasm/AnchorWatch.wasm plugin.debug.wasm +``` + +### Enable Debug Logging + +Set `enableDebug: true` in plugin configuration to see detailed logs: + +```json +{ + "enabled": true, + "enableDebug": true, + "configuration": { + "maxRadius": 50 + } +} +``` + +Then check logs: + +```bash +journalctl -u signalk -f | grep "example-anchor-watch-dotnet" +``` + +### Hot Reload + +After making changes: + +1. Rebuild: `dotnet publish -c Release` +2. Copy WASM: `cp bin/Release/net10.0/wasi-wasm/AnchorWatch.wasm plugin.wasm` +3. Copy to Signal K: `cp plugin.wasm ~/.signalk/node_modules/@signalk/example-anchor-watch-dotnet/` +4. Restart plugin in Admin UI (no server restart needed!) + +## Troubleshooting + +### Plugin doesn't load + +**Check:** + +- .NET 10 SDK installed: `dotnet --version` +- WASI SDK 25.0 installed and `WASI_SDK_PATH` environment variable set +- WASM file exists: `ls -lh plugin.wasm` +- WASM file is not empty: `file plugin.wasm` + +**Solution:** + +```bash +# Ensure WASI_SDK_PATH is set +export WASI_SDK_PATH=/path/to/wasi-sdk-25.0 + +# Build +dotnet publish -c Release +cp bin/Release/net10.0/wasi-wasm/AnchorWatch.wasm plugin.wasm +``` + +### PUT handler not found + +**Error:** `Handler function not found: handle_put_vessels_self_navigation_anchor_position` + +**Check:** + +- Handler function name matches the pattern: `handle_put_{context}_{path}` with dots → underscores +- Function has `[UnmanagedCallersOnly(EntryPoint = "...")]` attribute +- Function is `public static` + +### Build errors + +**Error:** `error NU1100: Unable to resolve 'Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk'` + +**Solution:** + +```bash +dotnet workload install wasi-experimental +dotnet workload restore +``` + +**Error:** `The type or namespace name 'UnmanagedCallersOnly' could not be found` + +**Solution:** Ensure you're using .NET 10: + +```bash +dotnet --version # Should show 10.0.x +``` + +### PUT request fails with 501 + +**Error:** `{"state":"COMPLETED","statusCode":501,"message":"Handler not implemented"}` + +**Cause:** Handler function export name doesn't match the registered path + +**Solution:** + +1. Check the path you registered: `navigation.anchor.position` +2. Convert to handler name: `handle_put_vessels_self_navigation_anchor_position` +3. Ensure the function is exported with exactly that name + +## C# WASM Development Tips + +### Memory Management + +- Use `Marshal.AllocHGlobal()` for allocating memory passed to FFI +- Remember to `Marshal.FreeHGlobal()` when done (not shown in this example for simplicity) +- Use `unsafe` blocks for pointer operations + +### String Marshaling + +```csharp +// Read UTF-8 string from WASM memory +private static string ReadString(IntPtr ptr, int len) +{ + var bytes = new byte[len]; + Marshal.Copy(ptr, bytes, 0, len); + return Encoding.UTF8.GetString(bytes); +} + +// Write UTF-8 string to WASM memory +private static IntPtr MarshalString(string str) +{ + var bytes = Encoding.UTF8.GetBytes(str + "\0"); + IntPtr ptr = Marshal.AllocHGlobal(bytes.Length); + Marshal.Copy(bytes, 0, ptr, bytes.Length); + return ptr; +} +``` + +### JSON Serialization + +Use `System.Text.Json` for JSON operations: + +```csharp +using System.Text.Json; +using System.Text.Json.Serialization; + +var obj = JsonSerializer.Deserialize(jsonString); +var json = JsonSerializer.Serialize(obj); +``` + +### FFI Declarations + +All FFI imports must use `[DllImport("env", EntryPoint = "...")]`: + +```csharp +[DllImport("env", EntryPoint = "sk_debug")] +public static extern void Debug(IntPtr messagePtr, int messageLen); +``` + +All exported functions must use `[UnmanagedCallersOnly(EntryPoint = "...")]`: + +```csharp +[UnmanagedCallersOnly(EntryPoint = "plugin_start")] +public static int Start(IntPtr configPtr, int configLen) { ... } +``` + +## Future Enhancements + +Potential additions for this example: + +- [ ] **Drag Detection** - Monitor current position and emit alarm when dragging +- [ ] **Distance Calculation** - Calculate distance from anchor using Haversine formula +- [ ] **History Tracking** - Store position history in VFS +- [ ] **Alarm Escalation** - Escalate alarm after sustained drag +- [ ] **Web UI** - Add HTML/CSS/JS dashboard in `public/` folder + +## Resources + +- **Signal K WASM Plugin Guide**: `../../wasm/WASM_PLUGIN_DEV_GUIDE.md` +- **.NET WASI Documentation**: https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/ +- **Signal K Specification**: https://signalk.org/specification/ +- **PUT Handler API**: `../../src/put.js` + +## Support + +- **GitHub Issues**: https://github.com/SignalK/signalk-server/issues +- **Signal K Slack**: #developers channel +- **Signal K Forum**: https://github.com/SignalK/signalk-server/discussions + +## License + +Apache-2.0 + +--- + +**Built with:** C# 14, .NET 10, WASI SDK 25.0 +**Binary Size:** ~12 MB (WASI Component Model bundle with .NET runtime) +**Runtime:** Requires WASI Component Model support (NOT currently available in Signal K) + +## Known Limitations + +### Runtime Incompatibility (Critical) + +**componentize-dotnet only works with Wasmtime and WAMR runtimes.** + +Signal K uses Node.js with jco transpilation, which is NOT a supported configuration. +Even though jco can transpile the Component Model WASM to JavaScript, the underlying +.NET NativeAOT function table initialization fails in V8. + +**Error observed:** + +``` +RuntimeError: null function or function signature mismatch + at pluginId (wasm://wasm/...) +``` + +This error occurs because: + +1. .NET NativeAOT uses WASM indirect call tables +2. These tables are initialized by `_initialize()` which works in Wasmtime +3. In V8 (via jco), the table entries remain null +4. Any call to a plugin function crashes + +### Build Issues (Solved but runtime still fails) + +1. **ThreadStatic attribute** - TLS doesn't work in WASI; patched by removing attribute +2. **Missing using statements** - wit-bindgen doesn't add `using System;`; patched during build + +### Binary Size + +~20 MB for a simple plugin due to bundled .NET runtime. Compare to: + +- AssemblyScript: 3-10 KB +- Rust: 50-200 KB + +## Investigation Timeline (Dec 2024) + +1. Built .NET 10 WASM component with componentize-dotnet ✅ +2. Transpiled with jco to JavaScript ✅ +3. Plugin loads in Signal K, `$init` completes ✅ +4. Calling `pluginId()` crashes with null function error ❌ +5. Tried `_initialize()` call - no effect ❌ +6. Tried `InitializeModules()` - crashes (already called) ❌ +7. Removed `[ThreadStatic]` - fixed build, not runtime ❌ +8. Discovered componentize-dotnet only supports Wasmtime/WAMR ❌ +9. No `@bytecodealliance/wasmtime` npm package exists ❌ +10. **Conclusion: Wait for better tooling** + +For technical details, see the upstream issue: https://github.com/bytecodealliance/componentize-dotnet/issues/103 + +## Future Possibilities + +1. **Native Wasmtime embedding** - If a proper `@bytecodealliance/wasmtime` npm package + is released, it could run .NET WASM components directly + +2. **Improved jco/V8 support** - The jco project may add better support for .NET + NativeAOT output in the future + +3. **Alternative .NET toolchain** - Microsoft or community may develop a compilation + path that produces V8-compatible WASM + +4. **.NET 9 or earlier** - Earlier .NET versions use different WASI approaches but + still require WASI-SDK and have similar runtime constraints diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/build.sh b/examples/wasm-plugins/example-anchor-watch-dotnet/build.sh new file mode 100644 index 000000000..e4be96e35 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/build.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Build script for anchor-watch-dotnet plugin + +set -e + +echo "Building anchor-watch-dotnet plugin..." + +# Check prerequisites +if ! command -v dotnet &> /dev/null; then + echo "Error: dotnet CLI not found. Please install .NET 10 SDK (Preview)." + echo "Download from: https://dotnet.microsoft.com/download/dotnet/10.0" + exit 1 +fi + +# Check .NET version (10+ preferred, 9+ minimum) +DOTNET_VERSION=$(dotnet --version | cut -d'.' -f1) +if [ "$DOTNET_VERSION" -lt 9 ]; then + echo "Error: .NET 9 or later required. Current version: $(dotnet --version)" + echo "For best WASI 3.0 support, use .NET 10 Preview" + exit 1 +fi + +if [ "$DOTNET_VERSION" -lt 10 ]; then + echo "Warning: Using .NET $DOTNET_VERSION. For best WASI 3.0 support, consider .NET 10 Preview" +fi + +# Check if WASI workload is installed +if ! dotnet workload list | grep -q wasi; then + echo "Error: WASI workload not installed." + echo "Install with: dotnet workload install wasi-experimental" + exit 1 +fi + +# Clean previous build +echo "Cleaning previous build..." +dotnet clean -c Release > /dev/null 2>&1 || true +rm -f plugin.wasm + +# Build +echo "Building Release configuration..." +dotnet build -c Release + +# Find and copy the WASM output +WASM_FILE=$(find bin/Release/net8.0/wasi-wasm -name "*.wasm" | head -n 1) + +if [ -z "$WASM_FILE" ]; then + echo "Error: WASM file not found in build output" + exit 1 +fi + +echo "Copying WASM binary..." +cp "$WASM_FILE" plugin.wasm + +# Show file size +SIZE=$(ls -lh plugin.wasm | awk '{print $5}') +echo "✓ Build successful! plugin.wasm ($SIZE)" + +echo "" +echo "To install to Signal K:" +echo " mkdir -p ~/.signalk/node_modules/@signalk/anchor-watch-dotnet" +echo " cp plugin.wasm package.json ~/.signalk/node_modules/@signalk/anchor-watch-dotnet/" diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/nuget.config b/examples/wasm-plugins/example-anchor-watch-dotnet/nuget.config new file mode 100644 index 000000000..9cca471d2 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/nuget.config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/package-lock.json b/examples/wasm-plugins/example-anchor-watch-dotnet/package-lock.json new file mode 100644 index 000000000..756ca7fd2 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/package-lock.json @@ -0,0 +1,23 @@ +{ + "name": "@signalk/anchor-watch-dotnet", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@signalk/anchor-watch-dotnet", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@bytecodealliance/preview2-shim": "^0.17.5" + }, + "devDependencies": {} + }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.5.tgz", + "integrity": "sha512-F4WYVC6aHOiOXSsG3WDGFALrkpb952+9/EIX119qIzDtYgE5tvbOnKeBb0Y+NMzGEsu3334GdHIRXQ6wibY0MA==", + "license": "(Apache-2.0 WITH LLVM-exception)" + } + } +} diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/package.json b/examples/wasm-plugins/example-anchor-watch-dotnet/package.json new file mode 100644 index 000000000..bd5f1b0c7 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/package.json @@ -0,0 +1,47 @@ +{ + "name": "@signalk/example-anchor-watch-dotnet", + "version": "0.1.0", + "description": "Anchor Watch WASM plugin built with C#/.NET 10 - monitors vessel position relative to anchor", + "keywords": [ + "signalk-wasm-plugin" + ], + "wasmManifest": "jco-output/dotnet.js", + "wasmFormat": "component-model", + "wasmCapabilities": { + "network": false, + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "serialPorts": false, + "putHandlers": true + }, + "signalk": { + "appIcon": "./resources/app-icon.svg", + "displayName": "Anchor Watch (.NET)" + }, + "author": "Signal K Team", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/SignalK/signalk-server.git", + "directory": "examples/wasm-plugins/example-anchor-watch-dotnet" + }, + "files": [ + "jco-output/**/*", + "wit/**/*", + "README.md" + ], + "scripts": { + "build": "dotnet build -c Release", + "build:debug": "dotnet build", + "clean": "dotnet clean && rm -rf obj bin jco-output", + "transpile": "npx @bytecodealliance/jco transpile bin/Debug/net10.0/wasi-wasm/native/AnchorWatch.wasm -o jco-output --name dotnet --map \"signalk:plugin/signalk-api=./signalk-api.js\" --tla-compat && node scripts/patch-jco-output.js", + "prepublishOnly": "npm run build && npm run transpile" + }, + "dependencies": { + "@bytecodealliance/preview2-shim": "^0.17.0" + }, + "devDependencies": { + "@bytecodealliance/jco": "^1.10.0" + } +} diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/scripts/patch-jco-output.js b/examples/wasm-plugins/example-anchor-watch-dotnet/scripts/patch-jco-output.js new file mode 100644 index 000000000..17f66daaa --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/scripts/patch-jco-output.js @@ -0,0 +1,49 @@ +/** + * Post-processing script for jco transpiled output + * + * This script patches the jco-generated JavaScript to add the _initialize + * and InitializeModules calls required by .NET NativeAOT WASM modules. + * + * .NET NativeAOT exports: + * - _initialize: WASI reactor initialization + * - InitializeModules: .NET runtime module initialization + * + * Both must be called before any other exports can be used. + * + * This is necessary because jco doesn't automatically call these for + * reactor-style WASI components. + */ + +const fs = require('fs') +const path = require('path') + +const jcoOutputPath = path.join(__dirname, '..', 'jco-output', 'dotnet.js') + +console.log('Patching jco output to add .NET initialization calls...') + +let content = fs.readFileSync(jcoOutputPath, 'utf8') + +// Check if already patched +if (content.includes('// Initialize .NET runtime')) { + console.log('Already patched, skipping.') + process.exit(0) +} + +// Find the line "realloc1 = exports1.cabi_realloc;" and add initialization calls after it +const searchPattern = 'realloc1 = exports1.cabi_realloc;' +const initializeCall = `realloc1 = exports1.cabi_realloc; + // Initialize .NET runtime - required before calling any exports + // Note: _initialize internally calls InitializeModules, so we only call _initialize + if (typeof exports1._initialize === 'function') { + exports1._initialize(); + }` + +if (!content.includes(searchPattern)) { + console.error('Could not find insertion point for initialization calls') + process.exit(1) +} + +content = content.replace(searchPattern, initializeCall) + +fs.writeFileSync(jcoOutputPath, content) +console.log('Successfully patched jco output with .NET initialization calls') diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/test-jco.mjs b/examples/wasm-plugins/example-anchor-watch-dotnet/test-jco.mjs new file mode 100644 index 000000000..ab3f06671 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/test-jco.mjs @@ -0,0 +1,17 @@ +// Test running the jco-transpiled .NET WASM module +import * as dotnet from './jco-output/dotnet.js' + +console.log('Loaded .NET WASM module via jco transpilation') +console.log('Exports:', Object.keys(dotnet)) + +// The component exports wasi:cli/run@0.2.0 +if (dotnet.run) { + console.log('Found run export:', dotnet.run) + try { + // Call the run function + const result = dotnet.run.run() + console.log('Run result:', result) + } catch (err) { + console.error('Run error:', err) + } +} diff --git a/examples/wasm-plugins/example-anchor-watch-dotnet/wit/signalk-plugin.wit b/examples/wasm-plugins/example-anchor-watch-dotnet/wit/signalk-plugin.wit new file mode 100644 index 000000000..5a397e9c8 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-dotnet/wit/signalk-plugin.wit @@ -0,0 +1,52 @@ +// Signal K WASM Plugin Interface Definition +// This WIT file defines the interface that Signal K WASM plugins must implement + +package signalk:plugin@1.0.0; + +/// The main plugin interface that all Signal K WASM plugins must export +interface plugin { + /// Returns the unique plugin identifier (e.g., "anchor-watch-dotnet") + plugin-id: func() -> string; + + /// Returns the human-readable plugin name (e.g., "Anchor Watch (.NET)") + plugin-name: func() -> string; + + /// Returns the JSON schema for plugin configuration + plugin-schema: func() -> string; + + /// Called when the plugin is started with configuration JSON + /// Returns 0 on success, non-zero on error + plugin-start: func(config: string) -> s32; + + /// Called when the plugin is stopped + /// Returns 0 on success, non-zero on error + plugin-stop: func() -> s32; +} + +/// Signal K host API functions that plugins can import +interface signalk-api { + /// Log a debug message + sk-debug: func(message: string); + + /// Set plugin status message + sk-set-status: func(message: string); + + /// Set plugin error message + sk-set-error: func(message: string); + + /// Emit a Signal K delta message (JSON string) + sk-handle-message: func(delta-json: string); + + /// Register a PUT handler for a path + /// Returns 1 on success, 0 on failure + sk-register-put-handler: func(context: string, path: string) -> s32; +} + +/// World definition for Signal K plugins +world signalk-plugin { + /// Import the Signal K host API + import signalk-api; + + /// Export the plugin interface + export plugin; +} diff --git a/examples/wasm-plugins/example-anchor-watch-rust/.gitignore b/examples/wasm-plugins/example-anchor-watch-rust/.gitignore new file mode 100644 index 000000000..fd1afc315 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-rust/.gitignore @@ -0,0 +1,18 @@ +# Rust build artifacts +target/ +Cargo.lock + +# Editor/IDE files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Built WASM (will be built fresh) +# Uncomment if you want to commit the pre-built WASM: +# !plugin.wasm diff --git a/examples/wasm-plugins/example-anchor-watch-rust/.npmignore b/examples/wasm-plugins/example-anchor-watch-rust/.npmignore new file mode 100644 index 000000000..e13f29b29 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-rust/.npmignore @@ -0,0 +1,27 @@ +# Rust build artifacts +target/ +Cargo.lock + +# Editor/IDE files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Development files +*.rs.bk +.rustfmt.toml +rustfmt.toml +.cargo/ + +# Keep only the essential files: +# - package.json +# - plugin.wasm +# - README.md +# - wit/ (for documentation) +# - src/ (optional, for reference) diff --git a/examples/wasm-plugins/example-anchor-watch-rust/Cargo.toml b/examples/wasm-plugins/example-anchor-watch-rust/Cargo.toml new file mode 100644 index 000000000..0148562e2 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-rust/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "anchor-watch-rust" +version = "0.1.0" +edition = "2021" +description = "Anchor Watch WASM plugin for Signal K - Rust implementation" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# JSON serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[profile.release] +# Optimize for size +opt-level = "s" +lto = true +strip = true +codegen-units = 1 +panic = "abort" diff --git a/examples/wasm-plugins/example-anchor-watch-rust/README.md b/examples/wasm-plugins/example-anchor-watch-rust/README.md new file mode 100644 index 000000000..db629474b --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-rust/README.md @@ -0,0 +1,395 @@ +# Example Anchor Watch - Rust WASM Plugin + +A Signal K WASM plugin written in Rust demonstrating: + +- Rust WASM compilation for Signal K (wasm32-wasip1 target) +- PUT handler registration and handling +- **Custom HTTP endpoints** (REST API) +- Delta message emission +- Plugin configuration via JSON schema +- Buffer-based FFI string passing + +## Status: Working + +This plugin is fully functional and tested on Signal K Server 3.0+ running on Raspberry Pi 5. + +## Features + +- **Anchor Position Tracking** - Set and monitor anchor position via PUT requests +- **Radius Alarm** - Configure maximum swing radius (10-1000 meters) +- **PUT Handlers** - Control anchor watch via Signal K PUT requests +- **Custom HTTP REST API** - Query status and drop anchor via HTTP endpoints +- **Real-time Updates** - Emits delta messages for state changes +- **Plugin State Control** - Anchor watch state tied to plugin enable/disable + +## Prerequisites + +### Rust Toolchain + +```bash +# Install Rust +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Add WASI Preview 1 target (required for Signal K WASM runtime) +rustup target add wasm32-wasip1 +``` + +## Building + +### WASI Preview 1 (Required) + +Signal K Server uses the WASI Preview 1 runtime. Build with: + +```bash +cargo build --release --target wasm32-wasip1 +``` + +Output: `target/wasm32-wasip1/release/anchor_watch_rust.wasm` + +### Copy to Plugin Directory + +```bash +cp target/wasm32-wasip1/release/anchor_watch_rust.wasm plugin.wasm +``` + +## Installation + +### Option 1: Direct Copy (Development) + +```bash +# Create plugin directory +mkdir -p ~/.signalk/node_modules/@signalk/example-anchor-watch-rust + +# Copy files +cp plugin.wasm package.json ~/.signalk/node_modules/@signalk/example-anchor-watch-rust/ +``` + +### Option 2: npm pack (Distribution) + +```bash +npm pack +# Install on target system +npm install -g ./signalk-anchor-watch-rust-0.1.0.tgz +``` + +## Configuration + +Enable and configure the plugin via the Signal K Admin UI under **Server → Plugin Config**. Configuration options are documented in the plugin's schema. + +## PUT Handlers + +The plugin registers PUT handlers for vessel control. **Important**: When multiple sources provide the same path, you must specify the source in the PUT request body. + +### navigation.anchor.position + +Set the anchor position: + +```bash +curl -X PUT http://localhost:3000/signalk/v1/api/vessels/self/navigation/anchor/position \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"value": {"latitude": 52.1234, "longitude": 4.5678}, "source": "@signalk/example-anchor-watch-rust"}' +``` + +### navigation.anchor.maxRadius + +Set the maximum swing radius (meters): + +```bash +curl -X PUT http://localhost:3000/signalk/v1/api/vessels/self/navigation/anchor/maxRadius \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"value": 75, "source": "@signalk/example-anchor-watch-rust"}' +``` + +### navigation.anchor.state + +Query anchor watch state (informational - state is controlled by enabling/disabling the plugin): + +```bash +curl -X PUT http://localhost:3000/signalk/v1/api/vessels/self/navigation/anchor/state \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"value": "on", "source": "@signalk/example-anchor-watch-rust"}' +``` + +**Note**: The anchor watch state is actually controlled by enabling/disabling the plugin itself. The PUT handler returns a success response but the actual state change requires toggling the plugin. + +## HTTP Endpoints (REST API) + +The plugin exposes custom HTTP endpoints for status queries and anchor control. These are mounted at `/plugins/_signalk_example-anchor-watch-rust/`. + +### GET /api/status + +Returns current anchor watch status: + +```bash +curl http://localhost:3000/plugins/_signalk_example-anchor-watch-rust/api/status +``` + +**Response:** + +```json +{ + "running": true, + "alarmActive": false, + "position": { "latitude": 52.1234, "longitude": 4.5678 }, + "maxRadius": 50, + "checkInterval": 10 +} +``` + +### GET /api/position + +Returns current anchor position: + +```bash +curl http://localhost:3000/plugins/_signalk_example-anchor-watch-rust/api/position +``` + +**Response:** + +```json +{ + "latitude": 52.1234, + "longitude": 4.5678, + "maxRadius": 50 +} +``` + +### POST /api/drop + +Drop anchor at a specified position: + +```bash +curl -X POST http://localhost:3000/plugins/_signalk_example-anchor-watch-rust/api/drop \ + -H "Content-Type: application/json" \ + -d '{"latitude": 52.1234, "longitude": 4.5678, "maxRadius": 75}' +``` + +**Request Body:** +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `latitude` | number | Yes | Latitude in degrees (-90 to 90) | +| `longitude` | number | Yes | Longitude in degrees (-180 to 180) | +| `maxRadius` | number | No | Max swing radius in meters (default: 50) | + +**Response:** + +```json +{ + "success": true, + "message": "Anchor dropped", + "position": { "latitude": 52.1234, "longitude": 4.5678 }, + "maxRadius": 75 +} +``` + +### HTTP Authentication + +Note: If Signal K server security is enabled, you need to authenticate first: + +```bash +# Login and save cookie +curl -X POST http://localhost:3000/signalk/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"yourpassword"}' \ + -c cookies.txt + +# Use cookie for API requests +curl -b cookies.txt http://localhost:3000/plugins/_signalk_example-anchor-watch-rust/api/status +``` + +## Source Parameter + +When multiple plugins/providers write to the same Signal K path, PUT requests require a `source` parameter to identify which handler should process the request. + +For this plugin, use: `"source": "@signalk/example-anchor-watch-rust"` + +The source name matches the npm package name declared in `package.json`. + +## Signal K Paths + +The plugin emits delta updates to these paths: + +| Path | Type | Description | +| ----------------------------- | ------------------------- | --------------------------------------------- | +| `navigation.anchor.position` | `{ latitude, longitude }` | Anchor position in degrees | +| `navigation.anchor.maxRadius` | number | Maximum swing radius in meters | +| `navigation.anchor.state` | string | "on" when plugin enabled, "off" when disabled | + +## Project Structure + +``` +example-anchor-watch-rust/ +├── Cargo.toml # Rust package manifest +├── package.json # npm package for Signal K +├── plugin.wasm # Built WASM binary (after build) +├── README.md +└── src/ + └── lib.rs # Plugin implementation +``` + +## Technical Details + +### FFI Interface + +The plugin uses raw FFI to communicate with the Signal K server: + +**Imports from host (env module):** + +- `sk_debug(ptr, len)` - Log debug message +- `sk_set_status(ptr, len)` - Set plugin status +- `sk_set_error(ptr, len)` - Set error message +- `sk_handle_message(ptr, len)` - Emit delta message +- `sk_register_put_handler(ctx_ptr, ctx_len, path_ptr, path_len)` - Register PUT handler + +**Exports to host:** + +- `plugin_id(out_ptr, max_len) -> len` - Return plugin ID +- `plugin_name(out_ptr, max_len) -> len` - Return plugin name +- `plugin_schema(out_ptr, max_len) -> len` - Return JSON schema +- `plugin_start(config_ptr, config_len) -> status` - Start plugin +- `plugin_stop() -> status` - Stop plugin +- `allocate(size) -> ptr` - Allocate memory for host-to-WASM strings +- `deallocate(ptr, size)` - Free allocated memory + +**PUT Handlers:** + +- `handle_put_vessels_self_navigation_anchor_position(value_ptr, value_len, response_ptr, response_max_len) -> len` +- `handle_put_vessels_self_navigation_anchor_maxRadius(value_ptr, value_len, response_ptr, response_max_len) -> len` +- `handle_put_vessels_self_navigation_anchor_state(value_ptr, value_len, response_ptr, response_max_len) -> len` + +**HTTP Endpoints:** + +- `http_endpoints(out_ptr, max_len) -> len` - Return JSON array of endpoint definitions +- `http_get_status(request_ptr, request_len, response_ptr, response_max_len) -> len` - GET /api/status +- `http_get_position(request_ptr, request_len, response_ptr, response_max_len) -> len` - GET /api/position +- `http_post_drop(request_ptr, request_len, response_ptr, response_max_len) -> len` - POST /api/drop + +### PUT Handler Naming Convention + +Handler function names follow this pattern: + +``` +handle_put_{context}_{path} +``` + +- Replace all dots (`.`) with underscores (`_`) +- Context: `vessels.self` → `vessels_self` +- Path: `navigation.anchor.position` → `navigation_anchor_position` + +### Memory Management + +Rust plugins use buffer-based string passing: + +1. Host calls `allocate(size)` to get memory for input +2. Host writes UTF-8 bytes to allocated memory +3. Plugin reads input and writes output to provided buffer +4. Host calls `deallocate(ptr, size)` to free memory + +This differs from AssemblyScript plugins which use the AS loader for automatic string conversion. + +## Development + +```bash +# Check code +cargo check --target wasm32-wasip1 + +# Build debug +cargo build --target wasm32-wasip1 + +# Build release (optimized) +cargo build --release --target wasm32-wasip1 + +# Copy to plugin.wasm +cp target/wasm32-wasip1/release/anchor_watch_rust.wasm plugin.wasm +``` + +### Debugging + +Enable debug logging on the Signal K server: + +```bash +DEBUG=signalk:wasm:* signalk-server +``` + +Or use journalctl on systemd systems: + +```bash +journalctl -u signalk -f | grep wasm +``` + +## Binary Size + +Rust WASM plugins are typically 50-200 KB when optimized: + +```bash +# Check size +ls -lh target/wasm32-wasip1/release/anchor_watch_rust.wasm +# Expected: ~100-150 KB +``` + +### Size Optimization + +The `Cargo.toml` includes optimizations: + +```toml +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +strip = true # Strip debug symbols +``` + +Further optimization with `wasm-opt` (optional): + +```bash +wasm-opt -Oz plugin.wasm -o plugin.optimized.wasm +``` + +## Troubleshooting + +### Plugin not loading + +- Verify `wasmManifest` in `package.json` points to correct file +- Check that `plugin.wasm` exists and is readable +- Enable debug logging: `DEBUG=signalk:wasm:*` + +### PUT handlers not registering + +- Check `"putHandlers": true` in `wasmCapabilities` +- Verify handler function names match the pattern exactly +- Check server logs for registration messages + +### HTTP endpoints returning 404 + +- Check `"httpEndpoints": true` in `wasmCapabilities` +- Verify `http_endpoints()` export returns valid JSON array +- Check that handler function names match exactly +- Enable debug logging: `DEBUG=signalk:wasm:*` + +### PUT requests return "multiple sources" error + +- Add `"source": "@signalk/example-anchor-watch-rust"` to the request body +- The source must match the package name in `package.json` + +### Memory errors + +- Ensure `allocate` and `deallocate` are exported +- Check buffer sizes in handler functions +- Verify UTF-8 encoding of all strings + +## Dependencies + +### Rust Crates (Cargo.toml) + +- `serde` (1.0) - JSON serialization with derive macros +- `serde_json` (1.0) - JSON parsing + +### No external WASM libraries needed + +The plugin uses only Rust standard library and serde for JSON. No wasm-bindgen or other WASM-specific crates required. + +## License + +Apache-2.0 diff --git a/examples/wasm-plugins/example-anchor-watch-rust/package.json b/examples/wasm-plugins/example-anchor-watch-rust/package.json new file mode 100644 index 000000000..a45edb56c --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-rust/package.json @@ -0,0 +1,39 @@ +{ + "name": "@signalk/example-anchor-watch-rust", + "version": "0.2.0", + "description": "Anchor Watch WASM plugin for Signal K - Rust implementation with PUT handlers", + "main": "plugin.wasm", + "scripts": { + "build": "cargo build --release --target wasm32-wasip1", + "build:component": "cargo component build --release", + "postbuild": "cp target/wasm32-wasip1/release/anchor_watch_rust.wasm plugin.wasm", + "clean": "cargo clean && rm -f plugin.wasm", + "check": "cargo check --target wasm32-wasip1" + }, + "keywords": [ + "signalk-wasm-plugin", + "signalk-category-safety", + "wasm", + "rust", + "anchor-watch" + ], + "author": "Signal K", + "license": "Apache-2.0", + "signalk-plugin-enabled-by-default": false, + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { + "network": false, + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "putHandlers": true, + "httpEndpoints": true + }, + "repository": { + "type": "git", + "url": "https://github.com/SignalK/signalk-server" + }, + "engines": { + "node": ">=18" + } +} diff --git a/examples/wasm-plugins/example-anchor-watch-rust/src/lib.rs b/examples/wasm-plugins/example-anchor-watch-rust/src/lib.rs new file mode 100644 index 000000000..e7446e150 --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-rust/src/lib.rs @@ -0,0 +1,553 @@ +//! Anchor Watch WASM Plugin for Signal K +//! +//! A Rust implementation demonstrating: +//! - WASM plugin architecture with raw FFI exports +//! - PUT handler registration and handling +//! - Custom HTTP endpoints (REST API) +//! - Delta message emission +//! - Plugin configuration via JSON schema + +use std::cell::RefCell; +use std::f64::consts::PI; +use serde::{Deserialize, Serialize}; + +// ============================================================================= +// FFI Imports - These must match what the SignalK WASM runtime provides in "env" +// ============================================================================= + +#[link(wasm_import_module = "env")] +extern "C" { + fn sk_debug(ptr: *const u8, len: usize); + fn sk_set_status(ptr: *const u8, len: usize); + fn sk_set_error(ptr: *const u8, len: usize); + fn sk_handle_message(ptr: *const u8, len: usize); + fn sk_register_put_handler(context_ptr: *const u8, context_len: usize, path_ptr: *const u8, path_len: usize) -> i32; +} + +// ============================================================================= +// Helper wrappers for FFI functions +// ============================================================================= + +fn debug(msg: &str) { + unsafe { sk_debug(msg.as_ptr(), msg.len()); } +} + +fn set_status(msg: &str) { + unsafe { sk_set_status(msg.as_ptr(), msg.len()); } +} + +fn set_error(msg: &str) { + unsafe { sk_set_error(msg.as_ptr(), msg.len()); } +} + +fn handle_message(msg: &str) { + unsafe { sk_handle_message(msg.as_ptr(), msg.len()); } +} + +fn register_put_handler(context: &str, path: &str) -> i32 { + unsafe { sk_register_put_handler(context.as_ptr(), context.len(), path.as_ptr(), path.len()) } +} + +// ============================================================================= +// Plugin State +// ============================================================================= + +thread_local! { + static STATE: RefCell = RefCell::new(PluginState::default()); +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PluginConfig { + #[serde(default)] + anchor_lat: f64, + #[serde(default)] + anchor_lon: f64, + #[serde(default = "default_max_radius")] + max_radius: f64, + #[serde(default = "default_interval")] + check_interval: u32, +} + +fn default_max_radius() -> f64 { 50.0 } +fn default_interval() -> u32 { 10 } + +#[derive(Debug, Default)] +struct PluginState { + config: PluginConfig, + is_running: bool, + #[allow(dead_code)] + last_distance: f64, + alarm_active: bool, +} + +// ============================================================================= +// Memory Allocation for string passing +// ============================================================================= + +/// Allocate memory for string passing from host +#[no_mangle] +pub extern "C" fn allocate(size: usize) -> *mut u8 { + let mut buf = Vec::with_capacity(size); + let ptr = buf.as_mut_ptr(); + std::mem::forget(buf); + ptr +} + +/// Deallocate memory +#[no_mangle] +pub extern "C" fn deallocate(ptr: *mut u8, size: usize) { + unsafe { + let _ = Vec::from_raw_parts(ptr, 0, size); + } +} + +// ============================================================================= +// Plugin Exports - Core plugin interface +// ============================================================================= + +static PLUGIN_ID: &str = "anchor-watch-rust"; +static PLUGIN_NAME: &str = "Anchor Watch (Rust)"; +static PLUGIN_SCHEMA: &str = r#"{ + "type": "object", + "title": "Anchor Watch Configuration", + "properties": { + "anchorLat": { + "type": "number", + "title": "Anchor Latitude", + "description": "Latitude where the anchor was dropped (degrees)", + "default": 0 + }, + "anchorLon": { + "type": "number", + "title": "Anchor Longitude", + "description": "Longitude where the anchor was dropped (degrees)", + "default": 0 + }, + "maxRadius": { + "type": "number", + "title": "Maximum Radius (meters)", + "description": "Alert if vessel moves beyond this distance from anchor", + "default": 50, + "minimum": 10, + "maximum": 1000 + }, + "checkInterval": { + "type": "integer", + "title": "Check Interval (seconds)", + "description": "How often to check vessel position", + "default": 10, + "minimum": 1, + "maximum": 300 + } + }, + "required": ["maxRadius"] +}"#; + +/// Return the plugin ID +#[no_mangle] +pub extern "C" fn plugin_id(out_ptr: *mut u8, out_max_len: usize) -> i32 { + write_string(PLUGIN_ID, out_ptr, out_max_len) +} + +/// Return the plugin name +#[no_mangle] +pub extern "C" fn plugin_name(out_ptr: *mut u8, out_max_len: usize) -> i32 { + write_string(PLUGIN_NAME, out_ptr, out_max_len) +} + +/// Return the plugin JSON schema +#[no_mangle] +pub extern "C" fn plugin_schema(out_ptr: *mut u8, out_max_len: usize) -> i32 { + write_string(PLUGIN_SCHEMA, out_ptr, out_max_len) +} + +/// Start the plugin with configuration +#[no_mangle] +pub extern "C" fn plugin_start(config_ptr: *const u8, config_len: usize) -> i32 { + let config_json = unsafe { + let slice = std::slice::from_raw_parts(config_ptr, config_len); + String::from_utf8_lossy(slice).to_string() + }; + + let parsed_config: PluginConfig = match serde_json::from_str(&config_json) { + Ok(c) => c, + Err(e) => { + set_error(&format!("Failed to parse config: {}", e)); + return 1; + } + }; + + STATE.with(|state| { + let mut s = state.borrow_mut(); + s.config = parsed_config.clone(); + s.is_running = true; + s.alarm_active = false; + }); + + debug(&format!( + "Anchor Watch started: anchor=({}, {}), radius={}m", + parsed_config.anchor_lat, + parsed_config.anchor_lon, + parsed_config.max_radius + )); + + // Register PUT handlers + if register_put_handler("vessels.self", "navigation.anchor.position") == 1 { + debug("Registered PUT handler for navigation.anchor.position"); + } + if register_put_handler("vessels.self", "navigation.anchor.maxRadius") == 1 { + debug("Registered PUT handler for navigation.anchor.maxRadius"); + } + if register_put_handler("vessels.self", "navigation.anchor.state") == 1 { + debug("Registered PUT handler for navigation.anchor.state"); + } + + // Plugin enabled = anchor watch active + set_status("Anchor watch active"); + emit_anchor_state(true, parsed_config.anchor_lat, parsed_config.anchor_lon, parsed_config.max_radius); + + 0 +} + +/// Stop the plugin +#[no_mangle] +pub extern "C" fn plugin_stop() -> i32 { + STATE.with(|state| { + let mut s = state.borrow_mut(); + s.is_running = false; + }); + + // Plugin stopped = anchor watch disabled + emit_anchor_state(false, 0.0, 0.0, 0.0); + debug("Anchor Watch stopped"); + set_status("Stopped"); + + 0 +} + +// ============================================================================= +// PUT Handlers +// ============================================================================= + +/// Handle PUT request for navigation.anchor.position +#[no_mangle] +pub extern "C" fn handle_put_vessels_self_navigation_anchor_position( + value_ptr: *const u8, + value_len: usize, + response_ptr: *mut u8, + response_max_len: usize, +) -> i32 { + let value_json = unsafe { + let slice = std::slice::from_raw_parts(value_ptr, value_len); + String::from_utf8_lossy(slice).to_string() + }; + + debug(&format!("PUT navigation.anchor.position: {}", value_json)); + + #[derive(Deserialize)] + struct Position { + latitude: f64, + longitude: f64, + } + + let result = match serde_json::from_str::(&value_json) { + Ok(pos) => { + STATE.with(|state| { + let mut s = state.borrow_mut(); + s.config.anchor_lat = pos.latitude; + s.config.anchor_lon = pos.longitude; + + emit_anchor_state( + s.is_running, // Plugin running = anchor watch active + pos.latitude, + pos.longitude, + s.config.max_radius + ); + }); + + set_status(&format!("Anchor position set: ({:.6}, {:.6})", pos.latitude, pos.longitude)); + r#"{"state":"COMPLETED","statusCode":200}"#.to_string() + } + Err(e) => { + set_error(&format!("Invalid position: {}", e)); + format!(r#"{{"state":"COMPLETED","statusCode":400,"message":"Invalid position format: {}"}}"#, e) + } + }; + + write_string(&result, response_ptr, response_max_len) +} + +/// Handle PUT request for navigation.anchor.maxRadius +#[no_mangle] +pub extern "C" fn handle_put_vessels_self_navigation_anchor_maxRadius( + value_ptr: *const u8, + value_len: usize, + response_ptr: *mut u8, + response_max_len: usize, +) -> i32 { + let value_json = unsafe { + let slice = std::slice::from_raw_parts(value_ptr, value_len); + String::from_utf8_lossy(slice).to_string() + }; + + debug(&format!("PUT navigation.anchor.maxRadius: {}", value_json)); + + let result = match serde_json::from_str::(&value_json) { + Ok(radius) if radius >= 10.0 && radius <= 1000.0 => { + STATE.with(|state| { + let mut s = state.borrow_mut(); + s.config.max_radius = radius; + + emit_anchor_state( + s.is_running, // Plugin running = anchor watch active + s.config.anchor_lat, + s.config.anchor_lon, + radius + ); + }); + + set_status(&format!("Max radius set: {}m", radius)); + r#"{"state":"COMPLETED","statusCode":200}"#.to_string() + } + Ok(_) => { + set_error("Radius must be between 10 and 1000 meters"); + r#"{"state":"COMPLETED","statusCode":400,"message":"Radius must be between 10 and 1000 meters"}"#.to_string() + } + Err(e) => { + set_error(&format!("Invalid radius: {}", e)); + format!(r#"{{"state":"COMPLETED","statusCode":400,"message":"Invalid radius format: {}"}}"#, e) + } + }; + + write_string(&result, response_ptr, response_max_len) +} + +/// Handle PUT request for navigation.anchor.state +/// Note: Anchor watch state is controlled by enabling/disabling the plugin +#[no_mangle] +pub extern "C" fn handle_put_vessels_self_navigation_anchor_state( + value_ptr: *const u8, + value_len: usize, + response_ptr: *mut u8, + response_max_len: usize, +) -> i32 { + let value_json = unsafe { + let slice = std::slice::from_raw_parts(value_ptr, value_len); + String::from_utf8_lossy(slice).to_string() + }; + + debug(&format!("PUT navigation.anchor.state: {} (state controlled by plugin enable/disable)", value_json)); + + // Anchor watch state is controlled by enabling/disabling the plugin itself + // This PUT handler is informational only - actual state change requires plugin restart + let result = r#"{"state":"COMPLETED","statusCode":200,"message":"Anchor watch state is controlled by enabling/disabling the plugin"}"#; + write_string(result, response_ptr, response_max_len) +} + +// ============================================================================= +// HTTP Endpoints - Custom REST API +// ============================================================================= + +/// Export HTTP endpoint definitions +/// Returns JSON array of endpoint definitions +#[no_mangle] +pub extern "C" fn http_endpoints(out_ptr: *mut u8, out_max_len: usize) -> i32 { + let endpoints = r#"[ + {"method": "GET", "path": "/api/status", "handler": "http_get_status"}, + {"method": "GET", "path": "/api/position", "handler": "http_get_position"}, + {"method": "POST", "path": "/api/drop", "handler": "http_post_drop"} + ]"#; + write_string(endpoints, out_ptr, out_max_len) +} + +/// GET /api/status - Return current anchor watch status +#[no_mangle] +pub extern "C" fn http_get_status( + _request_ptr: *const u8, + _request_len: usize, + response_ptr: *mut u8, + response_max_len: usize, +) -> i32 { + debug("HTTP GET /api/status"); + + let response = STATE.with(|state| { + let s = state.borrow(); + format!( + r#"{{"statusCode":200,"headers":{{"Content-Type":"application/json"}},"body":"{{\"running\":{},\"alarmActive\":{},\"position\":{{\"latitude\":{},\"longitude\":{}}},\"maxRadius\":{},\"checkInterval\":{}}}"}}"#, + s.is_running, + s.alarm_active, + s.config.anchor_lat, + s.config.anchor_lon, + s.config.max_radius, + s.config.check_interval + ) + }); + + write_string(&response, response_ptr, response_max_len) +} + +/// GET /api/position - Return current anchor position +#[no_mangle] +pub extern "C" fn http_get_position( + _request_ptr: *const u8, + _request_len: usize, + response_ptr: *mut u8, + response_max_len: usize, +) -> i32 { + debug("HTTP GET /api/position"); + + let response = STATE.with(|state| { + let s = state.borrow(); + format!( + r#"{{"statusCode":200,"headers":{{"Content-Type":"application/json"}},"body":"{{\"latitude\":{},\"longitude\":{},\"maxRadius\":{}}}"}}"#, + s.config.anchor_lat, + s.config.anchor_lon, + s.config.max_radius + ) + }); + + write_string(&response, response_ptr, response_max_len) +} + +/// POST /api/drop - Drop anchor at specified position +#[no_mangle] +pub extern "C" fn http_post_drop( + request_ptr: *const u8, + request_len: usize, + response_ptr: *mut u8, + response_max_len: usize, +) -> i32 { + debug("HTTP POST /api/drop"); + + // Read request context + let request_json = unsafe { + let slice = std::slice::from_raw_parts(request_ptr, request_len); + String::from_utf8_lossy(slice).to_string() + }; + + debug(&format!("Request: {}", request_json)); + + // Parse request to get body + #[derive(Deserialize)] + struct RequestContext { + body: Option, + } + + #[derive(Deserialize)] + struct DropRequest { + latitude: f64, + longitude: f64, + #[serde(default = "default_max_radius")] + #[serde(rename = "maxRadius")] + max_radius: f64, + } + + let response = match serde_json::from_str::(&request_json) { + Ok(ctx) => { + match ctx.body { + Some(drop_req) => { + // Validate coordinates + if drop_req.latitude < -90.0 || drop_req.latitude > 90.0 { + return write_string( + r#"{"statusCode":400,"headers":{"Content-Type":"application/json"},"body":"{\"error\":\"Invalid latitude. Must be between -90 and 90.\"}"}"#, + response_ptr, + response_max_len + ); + } + if drop_req.longitude < -180.0 || drop_req.longitude > 180.0 { + return write_string( + r#"{"statusCode":400,"headers":{"Content-Type":"application/json"},"body":"{\"error\":\"Invalid longitude. Must be between -180 and 180.\"}"}"#, + response_ptr, + response_max_len + ); + } + + // Update state + STATE.with(|state| { + let mut s = state.borrow_mut(); + s.config.anchor_lat = drop_req.latitude; + s.config.anchor_lon = drop_req.longitude; + s.config.max_radius = drop_req.max_radius; + s.alarm_active = false; + }); + + // Emit delta to Signal K + emit_anchor_state(true, drop_req.latitude, drop_req.longitude, drop_req.max_radius); + set_status(&format!("Anchor dropped at ({:.6}, {:.6})", drop_req.latitude, drop_req.longitude)); + + format!( + r#"{{"statusCode":200,"headers":{{"Content-Type":"application/json"}},"body":"{{\"success\":true,\"message\":\"Anchor dropped\",\"position\":{{\"latitude\":{},\"longitude\":{}}},\"maxRadius\":{}}}"}}"#, + drop_req.latitude, + drop_req.longitude, + drop_req.max_radius + ) + } + None => { + r#"{"statusCode":400,"headers":{"Content-Type":"application/json"},"body":"{\"error\":\"Missing request body. Expected {latitude, longitude, maxRadius?}\"}"}"#.to_string() + } + } + } + Err(e) => { + debug(&format!("Failed to parse request: {}", e)); + format!( + r#"{{"statusCode":400,"headers":{{"Content-Type":"application/json"}},"body":"{{\"error\":\"Invalid request format: {}\"}}"}}"#, + e.to_string().replace('"', "\\\"") + ) + } + }; + + write_string(&response, response_ptr, response_max_len) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +fn emit_anchor_state(enabled: bool, lat: f64, lon: f64, radius: f64) { + let state_value = if enabled { "on" } else { "off" }; + + // Note: Do not include source or timestamp - the server automatically sets + // $source to the plugin ID and fills in timestamp with current time. + let delta = if enabled && (lat != 0.0 || lon != 0.0) { + format!( + r#"{{"context":"vessels.self","updates":[{{"values":[{{"path":"navigation.anchor.position","value":{{"latitude":{},"longitude":{}}}}},{{"path":"navigation.anchor.maxRadius","value":{}}},{{"path":"navigation.anchor.state","value":"{}"}}]}}]}}"#, + lat, lon, radius, state_value + ) + } else { + format!( + r#"{{"context":"vessels.self","updates":[{{"values":[{{"path":"navigation.anchor.state","value":"{}"}}]}}]}}"#, + state_value + ) + }; + + handle_message(&delta); +} + +fn write_string(s: &str, ptr: *mut u8, max_len: usize) -> i32 { + let bytes = s.as_bytes(); + let len = bytes.len().min(max_len); + + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, len); + } + + len as i32 +} + +/// Calculate distance between two points using Haversine formula (meters) +#[allow(dead_code)] +fn haversine_distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 { + const EARTH_RADIUS_M: f64 = 6_371_000.0; + + let lat1_rad = lat1 * PI / 180.0; + let lat2_rad = lat2 * PI / 180.0; + let delta_lat = (lat2 - lat1) * PI / 180.0; + let delta_lon = (lon2 - lon1) * PI / 180.0; + + let a = (delta_lat / 2.0).sin().powi(2) + + lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2.0).sin().powi(2); + let c = 2.0 * a.sqrt().asin(); + + EARTH_RADIUS_M * c +} diff --git a/examples/wasm-plugins/example-anchor-watch-rust/wit/signalk-plugin.wit b/examples/wasm-plugins/example-anchor-watch-rust/wit/signalk-plugin.wit new file mode 100644 index 000000000..943592eda --- /dev/null +++ b/examples/wasm-plugins/example-anchor-watch-rust/wit/signalk-plugin.wit @@ -0,0 +1,52 @@ +// Signal K WASM Plugin Interface Definition for Rust +// This WIT file defines the interface that Signal K WASM plugins must implement + +package signalk:plugin@1.0.0; + +/// The main plugin interface that all Signal K WASM plugins must export +interface plugin { + /// Returns the unique plugin identifier (e.g., "anchor-watch-rust") + plugin-id: func() -> string; + + /// Returns the human-readable plugin name (e.g., "Anchor Watch (Rust)") + plugin-name: func() -> string; + + /// Returns the JSON schema for plugin configuration + plugin-schema: func() -> string; + + /// Called when the plugin is started with configuration JSON + /// Returns 0 on success, non-zero on error + plugin-start: func(config: string) -> s32; + + /// Called when the plugin is stopped + /// Returns 0 on success, non-zero on error + plugin-stop: func() -> s32; +} + +/// Signal K host API functions that plugins can import +interface signalk-api { + /// Log a debug message + sk-debug: func(message: string); + + /// Set plugin status message + sk-set-status: func(message: string); + + /// Set plugin error message + sk-set-error: func(message: string); + + /// Emit a Signal K delta message (JSON string) + sk-handle-message: func(delta-json: string); + + /// Register a PUT handler for a path + /// Returns 1 on success, 0 on failure + sk-register-put-handler: func(context: string, path: string) -> s32; +} + +/// World definition for Signal K plugins +world signalk-plugin { + /// Import the Signal K host API + import signalk-api; + + /// Export the plugin interface + export plugin; +} diff --git a/examples/wasm-plugins/example-hello-assemblyscript/.npmignore b/examples/wasm-plugins/example-hello-assemblyscript/.npmignore new file mode 100644 index 000000000..104590bce --- /dev/null +++ b/examples/wasm-plugins/example-hello-assemblyscript/.npmignore @@ -0,0 +1,2 @@ +# test builds +*.tgz diff --git a/examples/wasm-plugins/example-hello-assemblyscript/README.md b/examples/wasm-plugins/example-hello-assemblyscript/README.md new file mode 100644 index 000000000..b9ae1624a --- /dev/null +++ b/examples/wasm-plugins/example-hello-assemblyscript/README.md @@ -0,0 +1,205 @@ +# Example Hello AssemblyScript - Signal K WASM Plugin + +A minimal example of a Signal K WASM plugin written in AssemblyScript. + +## Features + +- Demonstrates AssemblyScript plugin structure +- Emits delta messages on startup and periodically via `poll()` +- Creates notifications +- Configurable update interval for periodic heartbeats +- HTTP Endpoints - Custom REST API +- Tiny binary size (~18 KB) + +## Prerequisites + +- Node.js >= 20 + +## Building + +```bash +# Install dependencies +npm install + +# Build release version +npm run build +``` + +This creates `plugin.wasm` in the current directory. + +For debug builds with additional symbols: + +```bash +npm run asbuild:debug +``` + +## Installing to Signal K + +**Note:** The AssemblyScript Plugin SDK is not yet published to npm. You must install it first. + +### Step 1: Install the SDK + +```bash +cd /path/to/signalk-server/packages/assemblyscript-plugin-sdk +npm pack + +cd ~/.signalk +npm install /path/to/signalk-assemblyscript-plugin-sdk-0.2.0.tgz +``` + +### Step 2: Install the plugin + +Option 1: Using npm pack (recommended) + +```bash +cd /path/to/example-hello-assemblyscript +npm pack + +cd ~/.signalk +npm install /path/to/signalk-example-hello-assemblyscript-0.1.0.tgz +``` + +Option 2: Manual copy + +```bash +mkdir -p ~/.signalk/node_modules/@signalk/example-hello-assemblyscript +cp plugin.wasm package.json ~/.signalk/node_modules/@signalk/example-hello-assemblyscript/ +``` + +## Enabling + +1. Navigate to **Server** → **Plugin Config** in Signal K admin UI +2. Find "Hello AssemblyScript Plugin" +3. Enable the plugin +4. Optionally enable "Debug logging" to see detailed output +5. Configure the welcome message and update interval if desired +6. Click **Submit** + +## What It Does + +When started, the plugin: + +1. Emits a welcome notification to `notifications.hello` +2. Emits plugin information to `plugins.hello-assemblyscript.info` +3. Emits periodic heartbeat deltas to `plugins.hello-assemblyscript.heartbeat` (configurable interval) +4. Registers HTTP endpoints for REST API access + +### Periodic Heartbeat + +The plugin demonstrates the `poll()` export which is called by the server every ~1 second. The plugin tracks elapsed time and emits a heartbeat delta when the configured `updateInterval` (default: 5000ms) has elapsed. + +Example heartbeat delta: + +```json +{ + "context": "vessels.self", + "updates": [ + { + "source": { "label": "hello-assemblyscript", "type": "plugin" }, + "values": [ + { + "path": "plugins.hello-assemblyscript.heartbeat", + "value": { + "count": 1, + "message": "Hello from AssemblyScript!", + "intervalMs": 5000 + } + } + ] + } + ] +} +``` + +### HTTP Endpoints + +The plugin exposes two REST API endpoints: + +**GET /plugins/\_signalk_example-hello-assemblyscript/api/info** + +```bash +curl http://localhost:3000/plugins/_signalk_example-hello-assemblyscript/api/info +``` + +Returns: + +```json +{ + "pluginName": "Hello AssemblyScript Plugin", + "language": "AssemblyScript", + "version": "0.1.0", + "message": "Hello from WASM!", + "capabilities": ["delta", "notifications", "http-endpoints"] +} +``` + +**GET /plugins/\_signalk_example-hello-assemblyscript/api/status** + +```bash +curl http://localhost:3000/plugins/_signalk_example-hello-assemblyscript/api/status +``` + +Returns: + +```json +{ + "status": "running", + "uptime": "N/A", + "memory": "sandboxed" +} +``` + +## Configuration + +| Option | Type | Default | Description | +| ---------------- | ------ | ---------------------------- | ------------------------------------------------- | +| `message` | string | "Hello from AssemblyScript!" | Welcome message shown in notifications | +| `updateInterval` | number | 5000 | Interval in milliseconds between heartbeat deltas | + +Configure via the Signal K Admin UI under **Server → Plugin Config**. + +## Development + +### Project Structure + +``` +example-hello-assemblyscript/ +├── assembly/ +│ └── index.ts # Plugin implementation +├── package.json # NPM package definition +├── asconfig.json # AssemblyScript build config +├── plugin.wasm # Compiled WASM binary (after build) +└── README.md # This file +``` + +### Key Exports + +The plugin exports these functions for the Signal K server: + +| Export | Description | +| ---------------------- | ----------------------------------------------- | +| `plugin_name()` | Returns the human-readable plugin name | +| `plugin_schema()` | Returns JSON schema for configuration UI | +| `plugin_start(config)` | Called when plugin is enabled | +| `plugin_stop()` | Called when plugin is disabled | +| `poll()` | Called every ~1 second for periodic tasks | +| `http_endpoints()` | Returns JSON array of HTTP endpoint definitions | + +### Debugging + +Enable debug logging in the plugin configuration, then check server logs: + +```bash +DEBUG=signalk:wasm:* npm start +``` + +You'll see messages like: + +``` +signalk:wasm:bindings [@signalk/example-hello-assemblyscript] Heartbeat #1 +signalk:wasm:bindings [@signalk/example-hello-assemblyscript] Emitting delta (v1): ... +``` + +## License + +Apache-2.0 diff --git a/examples/wasm-plugins/example-hello-assemblyscript/asconfig.json b/examples/wasm-plugins/example-hello-assemblyscript/asconfig.json new file mode 100644 index 000000000..7ff7b8870 --- /dev/null +++ b/examples/wasm-plugins/example-hello-assemblyscript/asconfig.json @@ -0,0 +1,24 @@ +{ + "targets": { + "release": { + "outFile": "plugin.wasm", + "sourceMap": false, + "optimize": true, + "shrinkLevel": 2, + "converge": true, + "noAssert": true, + "runtime": "incremental", + "exportRuntime": true + }, + "debug": { + "outFile": "build/plugin.debug.wasm", + "sourceMap": true, + "debug": true, + "runtime": "incremental", + "exportRuntime": true + } + }, + "options": { + "bindings": "esm" + } +} diff --git a/examples/wasm-plugins/example-hello-assemblyscript/assembly/index.ts b/examples/wasm-plugins/example-hello-assemblyscript/assembly/index.ts new file mode 100644 index 000000000..1320b8cf8 --- /dev/null +++ b/examples/wasm-plugins/example-hello-assemblyscript/assembly/index.ts @@ -0,0 +1,363 @@ +/** + * Hello World - AssemblyScript WASM Plugin + * + * Demonstrates basic AssemblyScript plugin structure for Signal K + */ + +import { + Plugin, + Delta, + Update, + PathValue, + Notification, + NotificationState, + emit, + setStatus, + setError, + debug +} from '@signalk/assemblyscript-plugin-sdk/assembly' + +/** + * Plugin configuration interface + */ +class HelloConfig { + message: string = 'Hello from AssemblyScript!' + updateInterval: i32 = 5000 + enableDebugLogging: boolean = false +} + +// Track elapsed time for polling (server calls poll() every ~1000ms) +let elapsedMs: i32 = 0 +let pollCount: i32 = 0 + +/** + * Hello World Plugin Implementation + */ +class HelloPlugin extends Plugin { + private config: HelloConfig = new HelloConfig() + + /** + * Helper to conditionally log debug messages + */ + private logDebug(message: string): void { + if (this.config.enableDebugLogging) { + debug(message) + } + } + + /** + * Plugin name shown in admin UI + * Note: Plugin ID is derived from package.json name + */ + name(): string { + return 'Hello AssemblyScript Plugin' + } + + /** + * JSON schema for configuration UI + */ + schema(): string { + return `{ + "type": "object", + "properties": { + "message": { + "type": "string", + "title": "Welcome Message", + "default": "Hello from AssemblyScript!" + }, + "updateInterval": { + "type": "number", + "title": "Update Interval (ms)", + "default": 5000 + } + } + }` + } + + /** + * Start plugin with configuration + */ + start(configJson: string): i32 { + // Parse configuration + // Note: For production, use a JSON parser like assemblyscript-json + // For this example, we do basic string parsing + + // Check enableDebug at root level (matches regular plugin config structure) + if ( + configJson.includes('"enableDebug":true') || + configJson.includes('"enableDebug": true') + ) { + this.config.enableDebugLogging = true + } + + // Parse updateInterval from config (basic string parsing) + const intervalKey = '"updateInterval":' + const intervalIdx = configJson.indexOf(intervalKey) + if (intervalIdx >= 0) { + const startIdx = intervalIdx + intervalKey.length + let endIdx = startIdx + while (endIdx < configJson.length) { + const c = configJson.charCodeAt(endIdx) + if (c < 48 || c > 57) break // Not a digit (0-9) + endIdx++ + } + if (endIdx > startIdx) { + const intervalStr = configJson.substring(startIdx, endIdx) + this.config.updateInterval = i32(parseInt(intervalStr) as i32) + } + } + + // Reset poll counters + elapsedMs = 0 + pollCount = 0 + + this.logDebug('========================================') + this.logDebug('Hello AssemblyScript plugin starting...') + this.logDebug(`Plugin Name: ${this.name()}`) + this.logDebug(`Configuration received: ${configJson}`) + this.logDebug( + `Debug logging: ${this.config.enableDebugLogging ? 'ENABLED' : 'DISABLED'}` + ) + this.logDebug(`Update interval: ${this.config.updateInterval}ms`) + this.logDebug('========================================') + + setStatus('Started successfully') + this.logDebug('Status set to: Started successfully') + + // Emit a welcome notification + this.logDebug('Emitting welcome notification...') + this.emitWelcomeNotification() + + // Emit a test delta + this.logDebug('Emitting test delta with plugin info...') + this.emitTestDelta() + + this.logDebug('========================================') + this.logDebug('Hello AssemblyScript plugin started successfully!') + this.logDebug('========================================') + return 0 // Success + } + + /** + * Stop plugin + */ + stop(): i32 { + this.logDebug('========================================') + this.logDebug('Hello AssemblyScript plugin stopping...') + setStatus('Stopped') + this.logDebug('Status set to: Stopped') + this.logDebug('Hello AssemblyScript plugin stopped successfully!') + this.logDebug('========================================') + return 0 // Success + } + + /** + * Emit a welcome notification + */ + private emitWelcomeNotification(): void { + this.logDebug('Building welcome notification...') + const notification = new Notification( + NotificationState.normal, + this.config.message + ) + + const pathValue = new PathValue( + 'notifications.hello', + notification.toJSON() + ) + + const update = new Update([pathValue]) + const delta = new Delta('vessels.self', [update]) + + emit(delta) + this.logDebug('✓ Welcome notification emitted to path: notifications.hello') + } + + /** + * Emit a test delta with plugin information + */ + private emitTestDelta(): void { + this.logDebug('Building plugin info delta...') + const pluginInfo = `{ + "name": "${this.name()}", + "language": "AssemblyScript", + "version": "0.1.0" + }` + + const pathValue = new PathValue( + 'plugins.hello-assemblyscript.info', + pluginInfo + ) + + const update = new Update([pathValue]) + const delta = new Delta('vessels.self', [update]) + + emit(delta) + this.logDebug( + '✓ Plugin info delta emitted to path: plugins.hello-assemblyscript.info' + ) + } + + /** + * Emit a periodic heartbeat delta + */ + emitHeartbeat(): void { + pollCount++ + this.logDebug(`Heartbeat #${pollCount}`) + + const heartbeatValue = `{ + "count": ${pollCount}, + "message": "${this.config.message}", + "intervalMs": ${this.config.updateInterval} + }` + + const pathValue = new PathValue( + 'plugins.hello-assemblyscript.heartbeat', + heartbeatValue + ) + + const update = new Update([pathValue]) + const delta = new Delta('vessels.self', [update]) + + emit(delta) + this.logDebug( + `✓ Heartbeat delta emitted to path: plugins.hello-assemblyscript.heartbeat` + ) + } + + /** + * Get update interval for polling + */ + getUpdateInterval(): i32 { + return this.config.updateInterval + } +} + +// Export plugin instance +// Signal K server will call the exported functions +const plugin = new HelloPlugin() + +// Plugin lifecycle exports +// Note: plugin_id() is no longer required - ID is derived from package.json name + +export function plugin_name(): string { + return plugin.name() +} + +export function plugin_schema(): string { + return plugin.schema() +} + +export function plugin_start(configPtr: usize, configLen: usize): i32 { + // Read config string from memory + const len = i32(configLen) + const configBytes = new Uint8Array(len) + for (let i: i32 = 0; i < len; i++) { + configBytes[i] = load(configPtr + i) + } + const configJson = String.UTF8.decode(configBytes.buffer) + + return plugin.start(configJson) +} + +export function plugin_stop(): i32 { + return plugin.stop() +} + +/** + * Poll function - called by server every ~1000ms + * Emits heartbeat delta when updateInterval has elapsed + */ +export function poll(): i32 { + // Server calls poll() every ~1000ms + elapsedMs += 1000 + + // Check if it's time to emit a heartbeat + if (elapsedMs >= plugin.getUpdateInterval()) { + plugin.emitHeartbeat() + elapsedMs = 0 + } + + return 0 // Success +} + +/** + * HTTP Endpoints (Phase 2) + * Register custom REST API endpoints + */ +export function http_endpoints(): string { + return `[ + { + "method": "GET", + "path": "/api/info", + "handler": "handle_get_info" + }, + { + "method": "GET", + "path": "/api/status", + "handler": "handle_get_status" + } + ]` +} + +/** + * Handle GET /api/info + * Returns plugin information + */ +export function handle_get_info(requestPtr: usize, requestLen: usize): string { + // Decode request from memory (not used in this simple example) + const requestBytes = new Uint8Array(i32(requestLen)) + for (let i: i32 = 0; i < i32(requestLen); i++) { + requestBytes[i] = load(requestPtr + i) + } + const requestJson = String.UTF8.decode(requestBytes.buffer) + + // Build response data + // Note: pluginId is derived from package.json name + const bodyJson = `{ + "pluginName": "${plugin.name()}", + "language": "AssemblyScript", + "version": "0.1.0", + "message": "Hello from WASM!", + "capabilities": ["delta", "notifications", "http-endpoints"] + }` + + // Escape for embedding in JSON response + const escapedBody = bodyJson.replaceAll('"', '\\"').replaceAll('\n', '\\n') + + // Return HTTP response + return `{ + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": "${escapedBody}" + }` +} + +/** + * Handle GET /api/status + * Returns runtime status + */ +export function handle_get_status( + requestPtr: usize, + requestLen: usize +): string { + const requestBytes = new Uint8Array(i32(requestLen)) + for (let i: i32 = 0; i < i32(requestLen); i++) { + requestBytes[i] = load(requestPtr + i) + } + const requestJson = String.UTF8.decode(requestBytes.buffer) + + const bodyJson = `{ + "status": "running", + "uptime": "N/A", + "memory": "sandboxed" + }` + + const escapedBody = bodyJson.replaceAll('"', '\\"').replaceAll('\n', '\\n') + + return `{ + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": "${escapedBody}" + }` +} diff --git a/examples/wasm-plugins/example-hello-assemblyscript/package-lock.json b/examples/wasm-plugins/example-hello-assemblyscript/package-lock.json new file mode 100644 index 000000000..c70b65baa --- /dev/null +++ b/examples/wasm-plugins/example-hello-assemblyscript/package-lock.json @@ -0,0 +1,139 @@ +{ + "name": "@signalk/hello-assemblyscript", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@signalk/hello-assemblyscript", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "signalk-assemblyscript-plugin-sdk": "^0.1.0" + }, + "devDependencies": { + "assemblyscript": "^0.27.0" + } + }, + "../../../packages/assemblyscript-plugin-sdk": { + "name": "@signalk/assemblyscript-plugin-sdk", + "version": "0.1.0", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "as-fetch": "^2.1.4", + "as-wasi": "^0.6.0" + }, + "devDependencies": { + "assemblyscript": "^0.27.0" + } + }, + "node_modules/@assemblyscript/wasi-shim": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@assemblyscript/wasi-shim/-/wasi-shim-0.1.0.tgz", + "integrity": "sha512-fSLH7MdJHf2uDW5llA5VCF/CG62Jp2WkYGui9/3vIWs3jDhViGeQF7nMYLUjpigluM5fnq61I6obtCETy39FZw==", + "license": "Apache-2.0", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, + "node_modules/as-fetch": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/as-fetch/-/as-fetch-2.1.4.tgz", + "integrity": "sha512-/Qu8lMtNHQFyLxjryrzbkXbTndTGY0VztuittBjdAxc5DEdIad1hOvRrE3Q1ZlpOiRJH4fjT652Nl0r1oz5R1Q==", + "license": "MIT", + "dependencies": { + "json-as": "^0.5.52" + } + }, + "node_modules/as-string-sink": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/as-string-sink/-/as-string-sink-0.5.3.tgz", + "integrity": "sha512-DN/QqfptDhi4FaBqehMN9wH5Dl/PcTSfCfzSmcdtf3OVYYIEip5iyVUcS6t40BAP/OoUwtxCCbD/dlr2kVG+3Q==", + "license": "MIT" + }, + "node_modules/as-variant": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/as-variant/-/as-variant-0.4.1.tgz", + "integrity": "sha512-NCeerOfp1JRXRcT4WCqOBANPoDTE2zqS4eN+jPtdyNWJybUgG/rX9ugUxICkSQZzCfnG84zzxr3osQbjAkyiww==", + "license": "MIT" + }, + "node_modules/as-virtual": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/as-virtual/-/as-virtual-0.1.9.tgz", + "integrity": "sha512-R1nR7TT0KcROL/TxSXmiX2Q+7CgUMrjT/y9IP07StStqWs32KT2mpadJNF//yHWRaIJWe6atqTqO0JzsdhkPcQ==", + "license": "MIT" + }, + "node_modules/as-wasi": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/as-wasi/-/as-wasi-0.6.0.tgz", + "integrity": "sha512-2EHzHhkdnDrgSuO6a1Lm0k/DIxjeupBUpDmli1xpB74y9ArQuvEunc1LqyPT4yfx10lTyaYkzDmelBAcasQH6g==", + "dependencies": { + "@assemblyscript/wasi-shim": "^0.1" + } + }, + "node_modules/assemblyscript": { + "version": "0.27.37", + "resolved": "https://registry.npmjs.org/assemblyscript/-/assemblyscript-0.27.37.tgz", + "integrity": "sha512-YtY5k3PiV3SyUQ6gRlR2OCn8dcVRwkpiG/k2T5buoL2ymH/Z/YbaYWbk/f9mO2HTgEtGWjPiAQrIuvA7G/63Gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "binaryen": "116.0.0-nightly.20240114", + "long": "^5.2.4" + }, + "bin": { + "asc": "bin/asc.js", + "asinit": "bin/asinit.js" + }, + "engines": { + "node": ">=18", + "npm": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, + "node_modules/binaryen": { + "version": "116.0.0-nightly.20240114", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz", + "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-opt": "bin/wasm-opt", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/json-as": { + "version": "0.5.67", + "resolved": "https://registry.npmjs.org/json-as/-/json-as-0.5.67.tgz", + "integrity": "sha512-DLIoK/JHUFYp9sn8XQ/Q2sM1b3dstt7XF/WTpwHI/6XUVuone7uIRKdiIxYWJcIPK1SinQsGg9MSLMBdUW2HlQ==", + "license": "MIT", + "dependencies": { + "as-string-sink": "^0.5.3", + "as-variant": "^0.4.1", + "as-virtual": "^0.1.9" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/signalk-assemblyscript-plugin-sdk": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/signalk-assemblyscript-plugin-sdk/-/signalk-assemblyscript-plugin-sdk-0.1.0.tgz", + "integrity": "sha512-1nDqpWXZqiu0MaVBGglztQHNwVWhIdavDnXbyn37u7yxNwu8iOGzOn8xNSTX1vtSvARu8MieTJr9MSsL9+icsg==", + "license": "Apache-2.0", + "dependencies": { + "as-fetch": "^2.1.4", + "as-wasi": "^0.6.0" + } + } + } +} diff --git a/examples/wasm-plugins/example-hello-assemblyscript/package.json b/examples/wasm-plugins/example-hello-assemblyscript/package.json new file mode 100644 index 000000000..5f7b23202 --- /dev/null +++ b/examples/wasm-plugins/example-hello-assemblyscript/package.json @@ -0,0 +1,31 @@ +{ + "name": "@signalk/example-hello-assemblyscript", + "version": "0.1.0", + "description": "Hello World WASM plugin written in AssemblyScript", + "keywords": [ + "signalk-wasm-plugin" + ], + "wasmManifest": "plugin.wasm", + "wasmCapabilities": { + "network": false, + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "serialPorts": false + }, + "author": "Signal K Team", + "license": "Apache-2.0", + "scripts": { + "asbuild:debug": "asc assembly/index.ts --target debug --outFile build/plugin.debug.wasm", + "asbuild:release": "asc assembly/index.ts --target release --outFile plugin.wasm --optimize --shrinkLevel 2", + "asbuild": "npm run asbuild:release", + "build": "npm run asbuild", + "test": "npm run asbuild:debug" + }, + "devDependencies": { + "assemblyscript": "^0.27.0" + }, + "dependencies": { + "@signalk/assemblyscript-plugin-sdk": "^0.2.0" + } +} diff --git a/examples/wasm-plugins/example-hello-assemblyscript/plugin.d.ts b/examples/wasm-plugins/example-hello-assemblyscript/plugin.d.ts new file mode 100644 index 000000000..1072a68a7 --- /dev/null +++ b/examples/wasm-plugins/example-hello-assemblyscript/plugin.d.ts @@ -0,0 +1,63 @@ +/** Exported memory */ +export declare const memory: WebAssembly.Memory +// Exported runtime interface +export declare function __new(size: number, id: number): number +export declare function __pin(ptr: number): number +export declare function __unpin(ptr: number): void +export declare function __collect(): void +export declare const __rtti_base: number +/** + * assembly/index/plugin_name + * @returns `~lib/string/String` + */ +export declare function plugin_name(): string +/** + * assembly/index/plugin_schema + * @returns `~lib/string/String` + */ +export declare function plugin_schema(): string +/** + * assembly/index/plugin_start + * @param configPtr `usize` + * @param configLen `usize` + * @returns `i32` + */ +export declare function plugin_start( + configPtr: number, + configLen: number +): number +/** + * assembly/index/plugin_stop + * @returns `i32` + */ +export declare function plugin_stop(): number +/** + * assembly/index/poll + * @returns `i32` + */ +export declare function poll(): number +/** + * assembly/index/http_endpoints + * @returns `~lib/string/String` + */ +export declare function http_endpoints(): string +/** + * assembly/index/handle_get_info + * @param requestPtr `usize` + * @param requestLen `usize` + * @returns `~lib/string/String` + */ +export declare function handle_get_info( + requestPtr: number, + requestLen: number +): string +/** + * assembly/index/handle_get_status + * @param requestPtr `usize` + * @param requestLen `usize` + * @returns `~lib/string/String` + */ +export declare function handle_get_status( + requestPtr: number, + requestLen: number +): string diff --git a/examples/wasm-plugins/example-hello-assemblyscript/plugin.js b/examples/wasm-plugins/example-hello-assemblyscript/plugin.js new file mode 100644 index 000000000..4e8eea06b --- /dev/null +++ b/examples/wasm-plugins/example-hello-assemblyscript/plugin.js @@ -0,0 +1,114 @@ +async function instantiate(module, imports = {}) { + const adaptedImports = { + env: Object.assign(Object.create(globalThis), imports.env || {}, { + abort(message, fileName, lineNumber, columnNumber) { + // ~lib/builtins/abort(~lib/string/String | null?, ~lib/string/String | null?, u32?, u32?) => void + message = __liftString(message >>> 0) + fileName = __liftString(fileName >>> 0) + lineNumber = lineNumber >>> 0 + columnNumber = columnNumber >>> 0 + ;(() => { + // @external.js + throw Error(`${message} in ${fileName}:${lineNumber}:${columnNumber}`) + })() + }, + sk_debug(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_debug_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_debug(msgPtr, msgLen) + }, + sk_set_status(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_set_status_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_set_status(msgPtr, msgLen) + }, + sk_handle_message(deltaPtr, deltaLen, version) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_handle_message_ffi(usize, usize, i32) => void + deltaPtr = deltaPtr >>> 0 + deltaLen = deltaLen >>> 0 + sk_handle_message(deltaPtr, deltaLen, version) + } + }) + } + const { exports } = await WebAssembly.instantiate(module, adaptedImports) + const memory = exports.memory || imports.env.memory + const adaptedExports = Object.setPrototypeOf( + { + plugin_name() { + // assembly/index/plugin_name() => ~lib/string/String + return __liftString(exports.plugin_name() >>> 0) + }, + plugin_schema() { + // assembly/index/plugin_schema() => ~lib/string/String + return __liftString(exports.plugin_schema() >>> 0) + }, + http_endpoints() { + // assembly/index/http_endpoints() => ~lib/string/String + return __liftString(exports.http_endpoints() >>> 0) + }, + handle_get_info(requestPtr, requestLen) { + // assembly/index/handle_get_info(usize, usize) => ~lib/string/String + return __liftString( + exports.handle_get_info(requestPtr, requestLen) >>> 0 + ) + }, + handle_get_status(requestPtr, requestLen) { + // assembly/index/handle_get_status(usize, usize) => ~lib/string/String + return __liftString( + exports.handle_get_status(requestPtr, requestLen) >>> 0 + ) + } + }, + exports + ) + function __liftString(pointer) { + if (!pointer) return null + const end = + (pointer + new Uint32Array(memory.buffer)[(pointer - 4) >>> 2]) >>> 1, + memoryU16 = new Uint16Array(memory.buffer) + let start = pointer >>> 1, + string = '' + while (end - start > 1024) + string += String.fromCharCode( + ...memoryU16.subarray(start, (start += 1024)) + ) + return string + String.fromCharCode(...memoryU16.subarray(start, end)) + } + return adaptedExports +} +export const { + memory, + __new, + __pin, + __unpin, + __collect, + __rtti_base, + plugin_name, + plugin_schema, + plugin_start, + plugin_stop, + poll, + http_endpoints, + handle_get_info, + handle_get_status +} = await (async (url) => + instantiate( + await (async () => { + const isNodeOrBun = + typeof process != 'undefined' && + process.versions != null && + (process.versions.node != null || process.versions.bun != null) + if (isNodeOrBun) { + return globalThis.WebAssembly.compile( + await (await import('node:fs/promises')).readFile(url) + ) + } else { + return await globalThis.WebAssembly.compileStreaming( + globalThis.fetch(url) + ) + } + })(), + {} + ))(new URL('plugin.wasm', import.meta.url)) diff --git a/examples/wasm-plugins/example-routes-waypoints/.npmignore b/examples/wasm-plugins/example-routes-waypoints/.npmignore new file mode 100644 index 000000000..88ae39394 --- /dev/null +++ b/examples/wasm-plugins/example-routes-waypoints/.npmignore @@ -0,0 +1,12 @@ +# Source files - not needed for runtime +assembly/ +asconfig.json + +# Dev/build artifacts +node_modules/ +*.tgz +*.debug.wasm + +# Generated JS files (keep only .wasm) +build/*.js +build/*.d.ts diff --git a/examples/wasm-plugins/example-routes-waypoints/README.md b/examples/wasm-plugins/example-routes-waypoints/README.md new file mode 100644 index 000000000..b70eb343c --- /dev/null +++ b/examples/wasm-plugins/example-routes-waypoints/README.md @@ -0,0 +1,222 @@ +# Routes & Waypoints Resource Provider Plugin Example + +This example demonstrates how to create a WASM plugin that provides **standard Signal K resource types** (routes and waypoints) via the Resource Provider API. + +## What is a Resource Provider? + +Signal K's Resource API provides generic CRUD operations for navigation data: + +``` +GET /signalk/v2/api/resources/{type} # List all +GET /signalk/v2/api/resources/{type}/{id} # Get one +POST /signalk/v2/api/resources/{type} # Create new +PUT /signalk/v2/api/resources/{type}/{id} # Update existing +DELETE /signalk/v2/api/resources/{type}/{id} # Delete +``` + +Standard resource types include: + +- `routes` - Navigation routes (GeoJSON LineString) +- `waypoints` - Navigation waypoints (GeoJSON Point) +- `notes` - Freeform notes +- `regions` - Geographic regions +- `charts` - Chart metadata + +## Features + +This plugin demonstrates: + +- Registering as a resource provider for **multiple types** (routes AND waypoints) +- Implementing all 4 CRUD handlers +- GeoJSON-compliant data structures +- Signal K schema compliance +- In-memory storage with pre-populated sample data + +## Sample Data + +The plugin comes with sample Helsinki-area navigation data: + +**Waypoints:** + +- Helsinki Marina (60.1695°N, 24.9560°E) +- Suomenlinna Anchorage (60.1450°N, 24.9880°E) +- Fuel Dock (60.1680°N, 24.9620°E) + +**Routes:** + +- "Marina to Suomenlinna" - 3.5km route with 3 waypoints + +## Building + +```bash +cd examples/wasm-plugins/example-routes-waypoints +npm install +npm run build +``` + +## Installation + +**Note:** The AssemblyScript Plugin SDK is not yet published to npm. Install it first - see [example-hello-assemblyscript](../example-hello-assemblyscript/README.md#installing-to-signal-k) for instructions. + +1. Build the plugin +2. Create installable package and install: + ```bash + npm pack + cd ~/.signalk + npm install /path/to/signalk-example-routes-waypoints-0.1.0.tgz + ``` +3. Restart Signal K server +4. Enable the plugin in the Admin UI + +## Configuration + +No configuration required. The plugin automatically loads sample data on startup. + +## Testing + +Once enabled, test the Resource API: + +```bash +# List all waypoints +curl http://localhost:3000/signalk/v2/api/resources/waypoints + +# Get a specific waypoint +curl http://localhost:3000/signalk/v2/api/resources/waypoints/a1b2c3d4-0001-4000-8000-000000000001 + +# List all routes +curl http://localhost:3000/signalk/v2/api/resources/routes + +# Get a specific route +curl http://localhost:3000/signalk/v2/api/resources/routes/b2c3d4e5-0001-4000-8000-000000000001 + +# Create a new waypoint +curl -X POST http://localhost:3000/signalk/v2/api/resources/waypoints \ + -H "Content-Type: application/json" \ + -d '{ + "name": "New Waypoint", + "description": "Test waypoint", + "type": "Waypoint", + "feature": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [25.0, 60.2] + }, + "properties": {} + } + }' + +# Delete a waypoint +curl -X DELETE http://localhost:3000/signalk/v2/api/resources/waypoints/a1b2c3d4-0001-4000-8000-000000000001 +``` + +## Data Formats + +### Waypoint (GeoJSON Point) + +```json +{ + "name": "Helsinki Marina", + "description": "Main marina in Helsinki harbor", + "type": "Marina", + "feature": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [24.956, 60.1695] + }, + "properties": {} + } +} +``` + +### Route (GeoJSON LineString) + +```json +{ + "name": "Marina to Suomenlinna", + "description": "Short trip from Helsinki Marina to Suomenlinna anchorage", + "distance": 3500, + "feature": { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [24.956, 60.1695], + [24.97, 60.16], + [24.988, 60.145] + ] + }, + "properties": { + "coordinatesMeta": [ + { "name": "Start - Marina" }, + { "name": "Channel marker" }, + { "name": "End - Anchorage" } + ] + } + } +} +``` + +## Implementation Details + +### Capability Declaration + +In `package.json`: + +```json +{ + "wasmManifest": "build/plugin.wasm", + "wasmCapabilities": { + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "resourceProvider": true + } +} +``` + +### Multiple Resource Type Registration + +The plugin registers for BOTH resource types in `start()`: + +```typescript +registerResourceProvider('routes') +registerResourceProvider('waypoints') +``` + +### Handler Exports + +The plugin exports these functions for resource operations: + +```typescript +// List resources (routes or waypoints based on resourceType in query) +export function resources_list_resources(queryJson: string): string + +// Get single resource +export function resources_get_resource(requestJson: string): string + +// Create/update resource +export function resources_set_resource(requestJson: string): string + +// Delete resource +export function resources_delete_resource(requestJson: string): string +``` + +### Request Format + +All handlers receive a JSON request with `resourceType` indicating which type is being accessed: + +```json +{ + "resourceType": "waypoints", + "id": "a1b2c3d4-0001-4000-8000-000000000001" +} +``` + +## See Also + +- [example-weather-plugin](../example-weather-plugin/) - Resource Provider with custom type +- [example-weather-provider](../example-weather-provider/) - Weather Provider API example +- [WASM Developer Guide](../../../docs/develop/plugins/wasm/README.md) +- [Signal K Resources API](https://signalk.org/specification/1.7.0/doc/resources.html) diff --git a/examples/wasm-plugins/example-routes-waypoints/asconfig.json b/examples/wasm-plugins/example-routes-waypoints/asconfig.json new file mode 100644 index 000000000..d6c5ddd4f --- /dev/null +++ b/examples/wasm-plugins/example-routes-waypoints/asconfig.json @@ -0,0 +1,24 @@ +{ + "targets": { + "release": { + "outFile": "build/plugin.wasm", + "sourceMap": false, + "optimize": true, + "shrinkLevel": 2, + "converge": true, + "noAssert": true, + "runtime": "stub", + "use": "abort=" + }, + "debug": { + "outFile": "build/plugin.debug.wasm", + "sourceMap": true, + "debug": true, + "runtime": "stub" + } + }, + "options": { + "bindings": "esm", + "exportRuntime": true + } +} diff --git a/examples/wasm-plugins/example-routes-waypoints/assembly/index.ts b/examples/wasm-plugins/example-routes-waypoints/assembly/index.ts new file mode 100644 index 000000000..409844d45 --- /dev/null +++ b/examples/wasm-plugins/example-routes-waypoints/assembly/index.ts @@ -0,0 +1,602 @@ +/** + * Routes & Waypoints Resource Provider Plugin Example + * + * Demonstrates: + * - Resource Provider capability for standard Signal K resource types + * - Routes: Navigation routes with GeoJSON LineString geometry + * - Waypoints: Navigation points with GeoJSON Point geometry + * - Full CRUD operations (list, get, set, delete) + */ + +import { + Plugin, + setStatus, + setError, + debug +} from '@signalk/assemblyscript-plugin-sdk/assembly' + +import { + registerResourceProvider, + ResourceGetRequest +} from '@signalk/assemblyscript-plugin-sdk/assembly/resources' + +// ===== Data Types ===== + +/** + * Waypoint data structure (GeoJSON Point) + */ +class Waypoint { + id: string = '' + name: string = '' + description: string = '' + type: string = 'Waypoint' // Waypoint, PoI, Race Mark, etc. + longitude: f64 = 0.0 + latitude: f64 = 0.0 + + toJSON(): string { + return ( + '{' + + '"name":"' + + this.name + + '",' + + '"description":"' + + this.description + + '",' + + '"type":"' + + this.type + + '",' + + '"feature":{' + + '"type":"Feature",' + + '"geometry":{' + + '"type":"Point",' + + '"coordinates":[' + + this.longitude.toString() + + ',' + + this.latitude.toString() + + ']' + + '},' + + '"properties":{}' + + '}' + + '}' + ) + } +} + +/** + * Route point metadata + */ +class RoutePoint { + name: string = '' + longitude: f64 = 0.0 + latitude: f64 = 0.0 +} + +/** + * Route data structure (GeoJSON LineString) + */ +class Route { + id: string = '' + name: string = '' + description: string = '' + distance: f64 = 0.0 + points: RoutePoint[] = [] + + toJSON(): string { + let coords = '' + let meta = '' + for (let i = 0; i < this.points.length; i++) { + if (i > 0) { + coords += ',' + meta += ',' + } + coords += + '[' + + this.points[i].longitude.toString() + + ',' + + this.points[i].latitude.toString() + + ']' + meta += '{"name":"' + this.points[i].name + '"}' + } + + return ( + '{' + + '"name":"' + + this.name + + '",' + + '"description":"' + + this.description + + '",' + + '"distance":' + + this.distance.toString() + + ',' + + '"feature":{' + + '"type":"Feature",' + + '"geometry":{' + + '"type":"LineString",' + + '"coordinates":[' + + coords + + ']' + + '},' + + '"properties":{' + + '"coordinatesMeta":[' + + meta + + ']' + + '}' + + '}' + + '}' + ) + } +} + +// ===== Storage ===== + +// Use arrays with linear search since AssemblyScript Map has limitations +const waypoints: Waypoint[] = [] +const routes: Route[] = [] + +// Track which resource type we're currently handling +const currentResourceType: string = '' + +// ===== Helper Functions ===== + +function findWaypointById(id: string): Waypoint | null { + for (let i = 0; i < waypoints.length; i++) { + if (waypoints[i].id === id) { + return waypoints[i] + } + } + return null +} + +function findRouteById(id: string): Route | null { + for (let i = 0; i < routes.length; i++) { + if (routes[i].id === id) { + return routes[i] + } + } + return null +} + +function deleteWaypointById(id: string): bool { + for (let i = 0; i < waypoints.length; i++) { + if (waypoints[i].id === id) { + waypoints.splice(i, 1) + return true + } + } + return false +} + +function deleteRouteById(id: string): bool { + for (let i = 0; i < routes.length; i++) { + if (routes[i].id === id) { + routes.splice(i, 1) + return true + } + } + return false +} + +function extractString(json: string, key: string): string { + const match = json.indexOf('"' + key + '":"') + if (match < 0) return '' + + const start = match + key.length + 4 + const end = json.indexOf('"', start) + if (end > start) { + return json.substring(start, end) + } + return '' +} + +function extractNumber(json: string, key: string): f64 { + const match = json.indexOf('"' + key + '":') + if (match < 0) return 0.0 + + const start = match + key.length + 3 + let end = start + while ( + end < json.length && + ((json.charCodeAt(end) >= 48 && json.charCodeAt(end) <= 57) || + json.charCodeAt(end) === 46 || + json.charCodeAt(end) === 45) + ) { + end++ + } + if (end > start) { + return parseFloat(json.substring(start, end)) + } + return 0.0 +} + +// Simple UUID-like ID generator (not true UUID, but valid format) +let idCounter: i32 = 0 +function generateId(): string { + idCounter++ + const hex = idCounter.toString(16).padStart(8, '0') + return hex + '-0000-4000-8000-000000000000' +} + +// ===== Initialize Sample Data ===== + +function initializeSampleData(): void { + // Sample Waypoints + const wp1 = new Waypoint() + wp1.id = 'a1b2c3d4-0001-4000-8000-000000000001' + wp1.name = 'Helsinki Marina' + wp1.description = 'Main marina in Helsinki harbor' + wp1.type = 'Marina' + wp1.longitude = 24.956 + wp1.latitude = 60.1695 + waypoints.push(wp1) + + const wp2 = new Waypoint() + wp2.id = 'a1b2c3d4-0002-4000-8000-000000000002' + wp2.name = 'Suomenlinna Anchorage' + wp2.description = 'Protected anchorage near Suomenlinna fortress' + wp2.type = 'Anchorage' + wp2.longitude = 24.988 + wp2.latitude = 60.145 + waypoints.push(wp2) + + const wp3 = new Waypoint() + wp3.id = 'a1b2c3d4-0003-4000-8000-000000000003' + wp3.name = 'Fuel Dock' + wp3.description = 'Diesel and petrol available' + wp3.type = 'Fuel Station' + wp3.longitude = 24.962 + wp3.latitude = 60.168 + waypoints.push(wp3) + + // Sample Route: Marina to Anchorage + const route1 = new Route() + route1.id = 'b2c3d4e5-0001-4000-8000-000000000001' + route1.name = 'Marina to Suomenlinna' + route1.description = + 'Short trip from Helsinki Marina to Suomenlinna anchorage' + route1.distance = 3500.0 // meters + + const pt1 = new RoutePoint() + pt1.name = 'Start - Marina' + pt1.longitude = 24.956 + pt1.latitude = 60.1695 + route1.points.push(pt1) + + const pt2 = new RoutePoint() + pt2.name = 'Channel marker' + pt2.longitude = 24.97 + pt2.latitude = 60.16 + route1.points.push(pt2) + + const pt3 = new RoutePoint() + pt3.name = 'End - Anchorage' + pt3.longitude = 24.988 + pt3.latitude = 60.145 + route1.points.push(pt3) + + routes.push(route1) + + debug( + 'Initialized sample data: ' + + waypoints.length.toString() + + ' waypoints, ' + + routes.length.toString() + + ' routes' + ) +} + +// ===== Plugin Class ===== + +class RoutesWaypointsPlugin extends Plugin { + // Note: Plugin ID is derived from package.json name + + name(): string { + return 'Routes & Waypoints Provider (Example)' + } + + start(configJson: string): i32 { + debug('Routes & Waypoints plugin starting...') + + // Initialize sample data + initializeSampleData() + + // Register as resource provider for BOTH types + debug('Registering as routes resource provider...') + if (registerResourceProvider('routes')) { + debug('Successfully registered for routes') + } else { + debug('Warning: Failed to register for routes') + } + + debug('Registering as waypoints resource provider...') + if (registerResourceProvider('waypoints')) { + debug('Successfully registered for waypoints') + } else { + debug('Warning: Failed to register for waypoints') + } + + setStatus( + 'Providing ' + + waypoints.length.toString() + + ' waypoints and ' + + routes.length.toString() + + ' routes' + ) + return 0 + } + + stop(): i32 { + debug('Routes & Waypoints plugin stopped') + setStatus('Stopped') + return 0 + } + + schema(): string { + return `{ + "type": "object", + "properties": { + "info": { + "type": "string", + "title": "Information", + "description": "This plugin provides sample routes and waypoints. No configuration needed.", + "default": "Routes and waypoints are pre-populated with sample data from Helsinki area." + } + } + }` + } +} + +// ===== Plugin Instance & Exports ===== + +const plugin = new RoutesWaypointsPlugin() + +// Note: plugin_id() is no longer required - ID is derived from package.json name + +export function plugin_name(): string { + return plugin.name() +} + +export function plugin_schema(): string { + return plugin.schema() +} + +export function plugin_start(configPtr: usize, configLen: usize): i32 { + const len = i32(configLen) + const configBytes = new Uint8Array(len) + for (let i: i32 = 0; i < len; i++) { + configBytes[i] = load(configPtr + i) + } + const configJson = String.UTF8.decode(configBytes.buffer) + return plugin.start(configJson) +} + +export function plugin_stop(): i32 { + return plugin.stop() +} + +// ===== Resource Provider Handler Exports ===== + +/** + * List resources + * Called for: GET /signalk/v2/api/resources/routes + * GET /signalk/v2/api/resources/waypoints + * + * @param queryJson - JSON with query parameters and resourceType + * @returns JSON object: { "id1": {...}, "id2": {...} } + */ +export function resources_list_resources(queryJson: string): string { + debug('resources_list_resources called: ' + queryJson) + + // Extract resource type from query + const resourceType = extractString(queryJson, 'resourceType') + debug('Resource type: ' + resourceType) + + if (resourceType === 'waypoints') { + let result = '{' + for (let i = 0; i < waypoints.length; i++) { + if (i > 0) result += ',' + result += '"' + waypoints[i].id + '":' + waypoints[i].toJSON() + } + result += '}' + return result + } else if (resourceType === 'routes') { + let result = '{' + for (let i = 0; i < routes.length; i++) { + if (i > 0) result += ',' + result += '"' + routes[i].id + '":' + routes[i].toJSON() + } + result += '}' + return result + } + + // Unknown type - return empty + return '{}' +} + +/** + * Get a specific resource + * Called for: GET /signalk/v2/api/resources/routes/{id} + * GET /signalk/v2/api/resources/waypoints/{id} + * + * @param requestJson - JSON with { "id": "...", "resourceType": "..." } + * @returns JSON object of the resource + */ +export function resources_get_resource(requestJson: string): string { + debug('resources_get_resource called: ' + requestJson) + + const req = ResourceGetRequest.parse(requestJson) + const resourceType = extractString(requestJson, 'resourceType') + + debug('Get ' + resourceType + ' id: ' + req.id) + + if (resourceType === 'waypoints') { + const wp = findWaypointById(req.id) + if (wp !== null) { + return (wp as Waypoint).toJSON() + } + return '{"error":"Waypoint not found: ' + req.id + '"}' + } else if (resourceType === 'routes') { + const route = findRouteById(req.id) + if (route !== null) { + return (route as Route).toJSON() + } + return '{"error":"Route not found: ' + req.id + '"}' + } + + return '{"error":"Unknown resource type"}' +} + +/** + * Create or update a resource + * Called for: POST/PUT /signalk/v2/api/resources/routes/{id} + * POST/PUT /signalk/v2/api/resources/waypoints/{id} + * + * @param requestJson - JSON with { "id": "...", "resourceType": "...", "value": {...} } + * @returns Empty string on success, error message on failure + */ +export function resources_set_resource(requestJson: string): string { + debug('resources_set_resource called: ' + requestJson) + + const id = extractString(requestJson, 'id') + const resourceType = extractString(requestJson, 'resourceType') + + debug('Set ' + resourceType + ' id: ' + id) + + if (resourceType === 'waypoints') { + // Parse waypoint data from value + const name = extractString(requestJson, 'name') + const description = extractString(requestJson, 'description') + const wpType = extractString(requestJson, 'type') + + // Try to extract coordinates from feature.geometry.coordinates + // This is a simplified parser - production code would need full JSON parsing + const coordsMatch = requestJson.indexOf('"coordinates":[') + let lon: f64 = 0.0 + let lat: f64 = 0.0 + if (coordsMatch >= 0) { + const coordsStart = coordsMatch + 15 + const coordsEnd = requestJson.indexOf(']', coordsStart) + if (coordsEnd > coordsStart) { + const coordsStr = requestJson.substring(coordsStart, coordsEnd) + const commaPos = coordsStr.indexOf(',') + if (commaPos > 0) { + lon = parseFloat(coordsStr.substring(0, commaPos)) + lat = parseFloat(coordsStr.substring(commaPos + 1)) + } + } + } + + // Check if waypoint exists + let wp = findWaypointById(id) + if (wp === null) { + // Create new waypoint + wp = new Waypoint() + wp.id = id.length > 0 ? id : generateId() + waypoints.push(wp) + debug('Created new waypoint: ' + wp.id) + } else { + debug('Updating existing waypoint: ' + wp.id) + } + + // Update fields + const w = wp as Waypoint + if (name.length > 0) w.name = name + if (description.length > 0) w.description = description + if (wpType.length > 0) w.type = wpType + if (lon !== 0.0) w.longitude = lon + if (lat !== 0.0) w.latitude = lat + + setStatus( + 'Providing ' + + waypoints.length.toString() + + ' waypoints and ' + + routes.length.toString() + + ' routes' + ) + return '' // Success + } else if (resourceType === 'routes') { + // Parse route data - simplified + const name = extractString(requestJson, 'name') + const description = extractString(requestJson, 'description') + const distance = extractNumber(requestJson, 'distance') + + // Check if route exists + let route = findRouteById(id) + if (route === null) { + // Create new route + route = new Route() + route.id = id.length > 0 ? id : generateId() + routes.push(route) + debug('Created new route: ' + route.id) + } else { + debug('Updating existing route: ' + route.id) + } + + // Update fields + const r = route as Route + if (name.length > 0) r.name = name + if (description.length > 0) r.description = description + if (distance > 0) r.distance = distance + + // Note: Full route point parsing would require more complex JSON parsing + // For this example, we just update metadata + + setStatus( + 'Providing ' + + waypoints.length.toString() + + ' waypoints and ' + + routes.length.toString() + + ' routes' + ) + return '' // Success + } + + return 'Unknown resource type' +} + +/** + * Delete a resource + * Called for: DELETE /signalk/v2/api/resources/routes/{id} + * DELETE /signalk/v2/api/resources/waypoints/{id} + * + * @param requestJson - JSON with { "id": "...", "resourceType": "..." } + * @returns Empty string on success, error message on failure + */ +export function resources_delete_resource(requestJson: string): string { + debug('resources_delete_resource called: ' + requestJson) + + const id = extractString(requestJson, 'id') + const resourceType = extractString(requestJson, 'resourceType') + + debug('Delete ' + resourceType + ' id: ' + id) + + if (resourceType === 'waypoints') { + if (deleteWaypointById(id)) { + debug('Deleted waypoint: ' + id) + setStatus( + 'Providing ' + + waypoints.length.toString() + + ' waypoints and ' + + routes.length.toString() + + ' routes' + ) + return '' // Success + } + return 'Waypoint not found: ' + id + } else if (resourceType === 'routes') { + if (deleteRouteById(id)) { + debug('Deleted route: ' + id) + setStatus( + 'Providing ' + + waypoints.length.toString() + + ' waypoints and ' + + routes.length.toString() + + ' routes' + ) + return '' // Success + } + return 'Route not found: ' + id + } + + return 'Unknown resource type' +} diff --git a/examples/wasm-plugins/example-routes-waypoints/build/plugin.d.ts b/examples/wasm-plugins/example-routes-waypoints/build/plugin.d.ts new file mode 100644 index 000000000..4f8c99f20 --- /dev/null +++ b/examples/wasm-plugins/example-routes-waypoints/build/plugin.d.ts @@ -0,0 +1,57 @@ +/** Exported memory */ +export declare const memory: WebAssembly.Memory +// Exported runtime interface +export declare function __new(size: number, id: number): number +export declare function __pin(ptr: number): number +export declare function __unpin(ptr: number): void +export declare function __collect(): void +export declare const __rtti_base: number +/** + * assembly/index/plugin_name + * @returns `~lib/string/String` + */ +export declare function plugin_name(): string +/** + * assembly/index/plugin_schema + * @returns `~lib/string/String` + */ +export declare function plugin_schema(): string +/** + * assembly/index/plugin_start + * @param configPtr `usize` + * @param configLen `usize` + * @returns `i32` + */ +export declare function plugin_start( + configPtr: number, + configLen: number +): number +/** + * assembly/index/plugin_stop + * @returns `i32` + */ +export declare function plugin_stop(): number +/** + * assembly/index/resources_list_resources + * @param queryJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function resources_list_resources(queryJson: string): string +/** + * assembly/index/resources_get_resource + * @param requestJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function resources_get_resource(requestJson: string): string +/** + * assembly/index/resources_set_resource + * @param requestJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function resources_set_resource(requestJson: string): string +/** + * assembly/index/resources_delete_resource + * @param requestJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function resources_delete_resource(requestJson: string): string diff --git a/examples/wasm-plugins/example-routes-waypoints/build/plugin.js b/examples/wasm-plugins/example-routes-waypoints/build/plugin.js new file mode 100644 index 000000000..a78bc305b --- /dev/null +++ b/examples/wasm-plugins/example-routes-waypoints/build/plugin.js @@ -0,0 +1,121 @@ +async function instantiate(module, imports = {}) { + const adaptedImports = { + env: Object.assign(Object.create(globalThis), imports.env || {}, { + sk_debug(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_debug_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_debug(msgPtr, msgLen) + }, + sk_register_resource_provider(typePtr, typeLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/resources/sk_register_resource_provider_ffi(usize, usize) => i32 + typePtr = typePtr >>> 0 + typeLen = typeLen >>> 0 + return sk_register_resource_provider(typePtr, typeLen) + }, + sk_set_status(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_set_status_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_set_status(msgPtr, msgLen) + } + }) + } + const { exports } = await WebAssembly.instantiate(module, adaptedImports) + const memory = exports.memory || imports.env.memory + const adaptedExports = Object.setPrototypeOf( + { + plugin_name() { + // assembly/index/plugin_name() => ~lib/string/String + return __liftString(exports.plugin_name() >>> 0) + }, + plugin_schema() { + // assembly/index/plugin_schema() => ~lib/string/String + return __liftString(exports.plugin_schema() >>> 0) + }, + resources_list_resources(queryJson) { + // assembly/index/resources_list_resources(~lib/string/String) => ~lib/string/String + queryJson = __lowerString(queryJson) || __notnull() + return __liftString(exports.resources_list_resources(queryJson) >>> 0) + }, + resources_get_resource(requestJson) { + // assembly/index/resources_get_resource(~lib/string/String) => ~lib/string/String + requestJson = __lowerString(requestJson) || __notnull() + return __liftString(exports.resources_get_resource(requestJson) >>> 0) + }, + resources_set_resource(requestJson) { + // assembly/index/resources_set_resource(~lib/string/String) => ~lib/string/String + requestJson = __lowerString(requestJson) || __notnull() + return __liftString(exports.resources_set_resource(requestJson) >>> 0) + }, + resources_delete_resource(requestJson) { + // assembly/index/resources_delete_resource(~lib/string/String) => ~lib/string/String + requestJson = __lowerString(requestJson) || __notnull() + return __liftString( + exports.resources_delete_resource(requestJson) >>> 0 + ) + } + }, + exports + ) + function __liftString(pointer) { + if (!pointer) return null + const end = + (pointer + new Uint32Array(memory.buffer)[(pointer - 4) >>> 2]) >>> 1, + memoryU16 = new Uint16Array(memory.buffer) + let start = pointer >>> 1, + string = '' + while (end - start > 1024) + string += String.fromCharCode( + ...memoryU16.subarray(start, (start += 1024)) + ) + return string + String.fromCharCode(...memoryU16.subarray(start, end)) + } + function __lowerString(value) { + if (value == null) return 0 + const length = value.length, + pointer = exports.__new(length << 1, 2) >>> 0, + memoryU16 = new Uint16Array(memory.buffer) + for (let i = 0; i < length; ++i) + memoryU16[(pointer >>> 1) + i] = value.charCodeAt(i) + return pointer + } + function __notnull() { + throw TypeError('value must not be null') + } + return adaptedExports +} +export const { + memory, + __new, + __pin, + __unpin, + __collect, + __rtti_base, + plugin_name, + plugin_schema, + plugin_start, + plugin_stop, + resources_list_resources, + resources_get_resource, + resources_set_resource, + resources_delete_resource +} = await (async (url) => + instantiate( + await (async () => { + const isNodeOrBun = + typeof process != 'undefined' && + process.versions != null && + (process.versions.node != null || process.versions.bun != null) + if (isNodeOrBun) { + return globalThis.WebAssembly.compile( + await (await import('node:fs/promises')).readFile(url) + ) + } else { + return await globalThis.WebAssembly.compileStreaming( + globalThis.fetch(url) + ) + } + })(), + {} + ))(new URL('plugin.wasm', import.meta.url)) diff --git a/examples/wasm-plugins/example-routes-waypoints/package.json b/examples/wasm-plugins/example-routes-waypoints/package.json new file mode 100644 index 000000000..862a20010 --- /dev/null +++ b/examples/wasm-plugins/example-routes-waypoints/package.json @@ -0,0 +1,38 @@ +{ + "name": "@signalk/example-routes-waypoints", + "version": "0.1.0", + "description": "Example WASM plugin demonstrating Routes and Waypoints Resource Providers", + "keywords": [ + "signalk-wasm-plugin", + "signalk", + "wasm", + "routes", + "waypoints", + "navigation", + "resource-provider" + ], + "wasmManifest": "build/plugin.wasm", + "wasmCapabilities": { + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "resourceProvider": true + }, + "author": "Signal K Team", + "license": "Apache-2.0", + "scripts": { + "asbuild:debug": "asc assembly/index.ts --target debug --outFile build/plugin.debug.wasm", + "asbuild:release": "asc assembly/index.ts --target release --outFile build/plugin.wasm", + "asbuild": "npm run asbuild:release", + "build": "npm run asbuild", + "clean": "rimraf build/" + }, + "devDependencies": { + "assemblyscript": "^0.27.0", + "rimraf": "^6.0.1" + }, + "dependencies": { + "assemblyscript-json": "^1.1.0", + "@signalk/assemblyscript-plugin-sdk": "^0.2.0" + } +} diff --git a/examples/wasm-plugins/example-weather-plugin/.npmignore b/examples/wasm-plugins/example-weather-plugin/.npmignore new file mode 100644 index 000000000..104590bce --- /dev/null +++ b/examples/wasm-plugins/example-weather-plugin/.npmignore @@ -0,0 +1,2 @@ +# test builds +*.tgz diff --git a/examples/wasm-plugins/example-weather-plugin/README.md b/examples/wasm-plugins/example-weather-plugin/README.md new file mode 100644 index 000000000..ccc5d7e12 --- /dev/null +++ b/examples/wasm-plugins/example-weather-plugin/README.md @@ -0,0 +1,107 @@ +# Weather Plugin Example + +This example demonstrates a WASM plugin with **network capability** and **resource provider** support using the AssemblyScript SDK with Asyncify. + +## Features + +- HTTP requests via `as-fetch` with Asyncify +- Resource provider for weather data REST API +- Real API integration with OpenWeatherMap +- Signal K delta emission + +## Signal K Paths + +The plugin emits deltas for: + +- `environment.outside.temperature` - Temperature in Kelvin +- `environment.outside.humidity` - Relative humidity (0-1) +- `environment.outside.pressure` - Pressure in Pascals +- `environment.wind.speedTrue` - Wind speed in m/s +- `environment.wind.directionTrue` - Wind direction in radians + +## Resource Provider API + +```bash +# List weather resources +curl http://localhost:3000/signalk/v2/api/resources/weather + +# Get current weather +curl http://localhost:3000/signalk/v2/api/resources/weather/current +``` + +## Prerequisites + +- Node.js 18+ (required for native fetch) +- OpenWeatherMap API key (free at https://openweathermap.org/api) +- Signal K Server 3.0+ + +## Building + +```bash +cd examples/wasm-plugins/example-weather-plugin +npm install +npm run build +``` + +## Installation + +**Note:** The AssemblyScript Plugin SDK is not yet published to npm. Install it first - see [example-hello-assemblyscript](../example-hello-assemblyscript/README.md#installing-to-signal-k) for instructions. + +1. Build the plugin +2. Create installable package and install: + ```bash + npm pack + cd ~/.signalk + npm install /path/to/signalk-example-weather-plugin-0.2.0.tgz + ``` +3. Restart Signal K server +4. Enable and configure in Admin UI + +## Configuration + +Configure via the Signal K Admin UI under **Server → Plugin Config**. You must provide a valid OpenWeatherMap API key. Configuration options are documented in the plugin's schema. + +## Key Implementation Details + +### Asyncify Configuration + +The `asconfig.json` must include the Asyncify transform: + +```json +{ + "options": { + "bindings": "esm", + "exportRuntime": true, + "transform": ["as-fetch/transform"] + } +} +``` + +### Capability Declaration + +The `package.json` declares required capabilities: + +```json +{ + "wasmCapabilities": { + "network": true, + "resourceProvider": true + } +} +``` + +## Debugging + +```bash +DEBUG=signalk:wasm:* npm start +``` + +## See Also + +- [AssemblyScript Plugin Guide](../../../docs/develop/plugins/wasm/assemblyscript.md) - Full documentation +- [WASM Developer Guide](../../../docs/develop/plugins/wasm/README.md) +- [as-fetch Library](https://github.com/nicoburniske/as-fetch) + +## License + +Apache-2.0 diff --git a/examples/wasm-plugins/example-weather-plugin/asconfig.json b/examples/wasm-plugins/example-weather-plugin/asconfig.json new file mode 100644 index 000000000..028a34968 --- /dev/null +++ b/examples/wasm-plugins/example-weather-plugin/asconfig.json @@ -0,0 +1,25 @@ +{ + "targets": { + "release": { + "outFile": "build/plugin.wasm", + "sourceMap": false, + "optimize": true, + "shrinkLevel": 2, + "converge": true, + "noAssert": true, + "runtime": "stub", + "use": "abort=" + }, + "debug": { + "outFile": "build/plugin.debug.wasm", + "sourceMap": true, + "debug": true, + "runtime": "stub" + } + }, + "options": { + "bindings": "esm", + "exportRuntime": true, + "transform": ["as-fetch/transform"] + } +} diff --git a/examples/wasm-plugins/example-weather-plugin/assembly/index.ts b/examples/wasm-plugins/example-weather-plugin/assembly/index.ts new file mode 100644 index 000000000..640bf470d --- /dev/null +++ b/examples/wasm-plugins/example-weather-plugin/assembly/index.ts @@ -0,0 +1,542 @@ +/** + * Weather Plugin Example for Signal K + * + * Demonstrates: + * - Network capability by fetching weather data from OpenWeatherMap API + * - Resource provider capability for serving weather data via REST API + * - Delta emission for real-time weather updates + */ + +import { + Plugin, + emit, + setStatus, + setError, + debug, + createSimpleDelta +} from '@signalk/assemblyscript-plugin-sdk/assembly' + +import { hasNetworkCapability } from '@signalk/assemblyscript-plugin-sdk/assembly/network' + +import { + registerResourceProvider, + ResourceGetRequest +} from '@signalk/assemblyscript-plugin-sdk/assembly/resources' + +import { fetchSync } from 'as-fetch/sync' +import { Response } from 'as-fetch/assembly' + +// Configuration interface +class WeatherConfig { + apiKey: string = '' + latitude: f64 = 0.0 + longitude: f64 = 0.0 + updateInterval: i32 = 600000 // 10 minutes default +} + +// Simple JSON parsing helpers +class WeatherData { + temperature: f64 = 0.0 + humidity: f64 = 0.0 + pressure: f64 = 0.0 + windSpeed: f64 = 0.0 + windDirection: f64 = 0.0 + description: string = '' + timestamp: string = '' + latitude: f64 = 0.0 + longitude: f64 = 0.0 + + toJSON(): string { + return ( + '{"temperature":' + + this.temperature.toString() + + ',"humidity":' + + this.humidity.toString() + + ',"pressure":' + + this.pressure.toString() + + ',"windSpeed":' + + this.windSpeed.toString() + + ',"windDirection":' + + this.windDirection.toString() + + ',"timestamp":"' + + this.timestamp + + '"' + + ',"location":{"latitude":' + + this.latitude.toString() + + ',"longitude":' + + this.longitude.toString() + + '}}' + ) + } + + static parse(json: string): WeatherData | null { + const data = new WeatherData() + + // Very basic JSON parsing - in production, use a proper JSON parser + // This is just for demonstration purposes + + // Extract temperature: "temp":293.15 + const tempMatch = json.indexOf('"temp":') + if (tempMatch >= 0) { + const tempStart = tempMatch + 7 + let tempEnd = tempStart + while ( + tempEnd < json.length && + ((json.charCodeAt(tempEnd) >= 48 && json.charCodeAt(tempEnd) <= 57) || + json.charCodeAt(tempEnd) === 46) + ) { + tempEnd++ + } + const tempStr = json.substring(tempStart, tempEnd) + data.temperature = parseFloat(tempStr) - 273.15 // Convert Kelvin to Celsius + } + + // Extract humidity: "humidity":60 + const humMatch = json.indexOf('"humidity":') + if (humMatch >= 0) { + const humStart = humMatch + 11 + let humEnd = humStart + while ( + humEnd < json.length && + json.charCodeAt(humEnd) >= 48 && + json.charCodeAt(humEnd) <= 57 + ) { + humEnd++ + } + const humStr = json.substring(humStart, humEnd) + data.humidity = parseFloat(humStr) + } + + // Extract pressure: "pressure":1013 + const pressMatch = json.indexOf('"pressure":') + if (pressMatch >= 0) { + const pressStart = pressMatch + 11 + let pressEnd = pressStart + while ( + pressEnd < json.length && + json.charCodeAt(pressEnd) >= 48 && + json.charCodeAt(pressEnd) <= 57 + ) { + pressEnd++ + } + const pressStr = json.substring(pressStart, pressEnd) + data.pressure = parseFloat(pressStr) * 100.0 // Convert hPa to Pa + } + + // Extract wind speed: "speed":5.2 + const speedMatch = json.indexOf('"speed":') + if (speedMatch >= 0) { + const speedStart = speedMatch + 8 + let speedEnd = speedStart + while ( + speedEnd < json.length && + ((json.charCodeAt(speedEnd) >= 48 && json.charCodeAt(speedEnd) <= 57) || + json.charCodeAt(speedEnd) === 46) + ) { + speedEnd++ + } + const speedStr = json.substring(speedStart, speedEnd) + data.windSpeed = parseFloat(speedStr) + } + + // Extract wind direction: "deg":180 + const degMatch = json.indexOf('"deg":') + if (degMatch >= 0) { + const degStart = degMatch + 6 + let degEnd = degStart + while ( + degEnd < json.length && + json.charCodeAt(degEnd) >= 48 && + json.charCodeAt(degEnd) <= 57 + ) { + degEnd++ + } + const degStr = json.substring(degStart, degEnd) + // Convert degrees to radians + data.windDirection = (parseFloat(degStr) * 3.14159265359) / 180.0 + } + + return data + } +} + +// Cached weather data for resource provider +let cachedWeatherData: WeatherData | null = null +let cachedConfig: WeatherConfig = new WeatherConfig() + +// Weather plugin class +class WeatherPlugin extends Plugin { + private config: WeatherConfig = new WeatherConfig() + private lastUpdate: i64 = 0 + + // Note: Plugin ID is derived from package.json name + + name(): string { + return 'Weather Data Plugin (Example)' + } + + start(configJson: string): i32 { + debug('Weather plugin starting...') + + // Check if network capability is available + if (!hasNetworkCapability()) { + setError('Network capability not granted - cannot fetch weather data') + return 1 + } + + // Parse configuration + debug('Parsing config JSON') + debug(configJson) + if (configJson.length > 2) { + // Very basic config parsing - extract apiKey, lat, lon + // Fixed: indexOf searches FROM the given position, including that position + // So we need to search from AFTER the colon + const apiKeyMatch = configJson.indexOf('"apiKey"') + debug('apiKeyMatch index:') + debug(apiKeyMatch.toString()) + if (apiKeyMatch >= 0) { + // Find the opening quote of the value (after the colon) + const colonPos = configJson.indexOf(':', apiKeyMatch) + const quoteStart = configJson.indexOf('"', colonPos) + if (quoteStart >= 0) { + const keyStart = quoteStart + 1 // Position after opening quote + const keyEnd = configJson.indexOf('"', keyStart) // Find closing quote + if (keyEnd > keyStart) { + this.config.apiKey = configJson.substring(keyStart, keyEnd) + } + } + } + + const latMatch = configJson.indexOf('"latitude"') + if (latMatch >= 0) { + const colonPos = configJson.indexOf(':', latMatch) + let latStart = colonPos + 1 + let latEnd = latStart + // Skip whitespace + while ( + latEnd < configJson.length && + (configJson.charCodeAt(latEnd) === 32 || + configJson.charCodeAt(latEnd) === 9) + ) { + latEnd++ + } + latStart = latEnd + // Read number + while ( + latEnd < configJson.length && + ((configJson.charCodeAt(latEnd) >= 48 && + configJson.charCodeAt(latEnd) <= 57) || + configJson.charCodeAt(latEnd) === 46 || + configJson.charCodeAt(latEnd) === 45) + ) { + latEnd++ + } + if (latEnd > latStart) { + this.config.latitude = parseFloat( + configJson.substring(latStart, latEnd) + ) + } + } + + const lonMatch = configJson.indexOf('"longitude"') + if (lonMatch >= 0) { + const colonPos = configJson.indexOf(':', lonMatch) + let lonStart = colonPos + 1 + let lonEnd = lonStart + // Skip whitespace + while ( + lonEnd < configJson.length && + (configJson.charCodeAt(lonEnd) === 32 || + configJson.charCodeAt(lonEnd) === 9) + ) { + lonEnd++ + } + lonStart = lonEnd + // Read number + while ( + lonEnd < configJson.length && + ((configJson.charCodeAt(lonEnd) >= 48 && + configJson.charCodeAt(lonEnd) <= 57) || + configJson.charCodeAt(lonEnd) === 46 || + configJson.charCodeAt(lonEnd) === 45) + ) { + lonEnd++ + } + if (lonEnd > lonStart) { + this.config.longitude = parseFloat( + configJson.substring(lonStart, lonEnd) + ) + } + } + } + + // Validate configuration + if (this.config.apiKey.length === 0) { + setError( + 'No API key configured - get one from https://openweathermap.org/api' + ) + return 1 + } + + // Store config globally for resource provider handlers + cachedConfig = this.config + + // Register as a resource provider for "weather" type + debug('Registering as weather resource provider...') + if (registerResourceProvider('weather')) { + debug('Successfully registered as weather resource provider') + } else { + debug( + 'Warning: Failed to register as resource provider (capability may not be granted)' + ) + } + + // Fetch and emit real weather data using as-fetch + // This demonstrates the Asyncify integration for synchronous-style async operations + this.fetchWeatherData() + setStatus('Weather plugin running - data fetched from OpenWeatherMap') + + return 0 + } + + stop(): i32 { + debug('Weather plugin stopped') + setStatus('Stopped') + return 0 + } + + schema(): string { + return `{ + "type": "object", + "required": ["apiKey", "latitude", "longitude"], + "properties": { + "apiKey": { + "type": "string", + "title": "OpenWeatherMap API Key", + "description": "Get your free API key from https://openweathermap.org/api" + }, + "latitude": { + "type": "number", + "title": "Latitude", + "description": "Latitude for weather location", + "default": 60.1699 + }, + "longitude": { + "type": "number", + "title": "Longitude", + "description": "Longitude for weather location", + "default": 24.9384 + }, + "updateInterval": { + "type": "number", + "title": "Update Interval (ms)", + "description": "How often to fetch weather data", + "default": 600000 + } + } + }` + } + + private emitTestWeatherData(): void { + debug('Emitting test weather data...') + + // Emit test temperature (15°C) + const tempDelta = createSimpleDelta( + 'environment.outside.temperature', + '288.15' // 15°C in Kelvin + ) + emit(tempDelta) + + // Emit test humidity (65%) + const humDelta = createSimpleDelta( + 'environment.outside.humidity', + '0.65' + ) + emit(humDelta) + + // Emit test pressure (101300 Pa) + const pressDelta = createSimpleDelta( + 'environment.outside.pressure', + '101300' + ) + emit(pressDelta) + + // Emit test wind speed (5.2 m/s) + const windSpeedDelta = createSimpleDelta( + 'environment.wind.speedTrue', + '5.2' + ) + emit(windSpeedDelta) + + // Emit test wind direction (180° = 3.14159 radians) + const windDirDelta = createSimpleDelta( + 'environment.wind.directionTrue', + '3.14159' + ) + emit(windDirDelta) + + debug('Test weather data emitted successfully') + } + + private fetchWeatherData(): void { + // Build API URL + const lat = this.config.latitude.toString() + const lon = this.config.longitude.toString() + const url = + 'https://api.openweathermap.org/data/2.5/weather?lat=' + + lat + + '&lon=' + + lon + + '&appid=' + + this.config.apiKey + + debug('Fetching weather data from: ' + url) + + // Fetch weather data using as-fetch synchronously + const fetchResponse = fetchSync(url) + + if (!fetchResponse.ok) { + setError( + 'Failed to fetch weather data - HTTP status: ' + + fetchResponse.status.toString() + ) + return + } + + const response = fetchResponse.text() + debug('Received weather response: ' + response.substring(0, 100) + '...') + + // Parse weather data + const weatherData = WeatherData.parse(response) + if (weatherData === null) { + setError('Failed to parse weather data') + return + } + + // Add location for resource provider (timestamp will be set by server) + weatherData.latitude = this.config.latitude + weatherData.longitude = this.config.longitude + + // Cache the weather data for resource provider queries + cachedWeatherData = weatherData + + // Emit temperature + const tempDelta = createSimpleDelta( + 'environment.outside.temperature', + weatherData.temperature.toString() + ) + emit(tempDelta) + + // Emit humidity + const humDelta = createSimpleDelta( + 'environment.outside.humidity', + (weatherData.humidity / 100.0).toString() + ) + emit(humDelta) + + // Emit pressure + const pressDelta = createSimpleDelta( + 'environment.outside.pressure', + weatherData.pressure.toString() + ) + emit(pressDelta) + + // Emit wind speed + const windSpeedDelta = createSimpleDelta( + 'environment.wind.speedTrue', + weatherData.windSpeed.toString() + ) + emit(windSpeedDelta) + + // Emit wind direction + const windDirDelta = createSimpleDelta( + 'environment.wind.directionTrue', + weatherData.windDirection.toString() + ) + emit(windDirDelta) + + setStatus('Weather data updated') + debug('Weather data emitted successfully') + } +} + +// Export plugin instance +const plugin = new WeatherPlugin() + +// Plugin lifecycle exports +// Note: plugin_id() is no longer required - ID is derived from package.json name + +export function plugin_name(): string { + return plugin.name() +} + +export function plugin_schema(): string { + return plugin.schema() +} + +export function plugin_start(configPtr: usize, configLen: usize): i32 { + // Read config string from memory + const len = i32(configLen) + const configBytes = new Uint8Array(len) + for (let i: i32 = 0; i < len; i++) { + configBytes[i] = load(configPtr + i) + } + const configJson = String.UTF8.decode(configBytes.buffer) + + return plugin.start(configJson) +} + +export function plugin_stop(): i32 { + return plugin.stop() +} + +// ===== Resource Provider Handlers ===== +// These are called by the Signal K server when requests come in to +// /signalk/v2/api/resources/weather + +/** + * List available weather resources + * Called for: GET /signalk/v2/api/resources/weather + * + * @param queryJson - JSON string with query parameters (e.g., filters) + * @returns JSON object of resources: { "id": { ...resource... }, ... } + */ +export function resources_list_resources(queryJson: string): string { + debug('resources_list_resources called with query: ' + queryJson) + + // Return available weather resources + // We have one resource: "current" for current weather conditions + if (cachedWeatherData !== null) { + const data = cachedWeatherData as WeatherData + return '{"current":' + data.toJSON() + '}' + } + + // No data available yet + return '{}' +} + +/** + * Get a specific weather resource + * Called for: GET /signalk/v2/api/resources/weather/{id} + * + * @param requestJson - JSON with { "id": "resource-id", "property": optional } + * @returns JSON object of the resource + */ +export function resources_get_resource(requestJson: string): string { + debug('resources_get_resource called with request: ' + requestJson) + + // Parse the request to get the ID + const req = ResourceGetRequest.parse(requestJson) + debug('Parsed request id: ' + req.id) + + if (req.id === 'current') { + if (cachedWeatherData !== null) { + const data = cachedWeatherData as WeatherData + return data.toJSON() + } + return '{"error":"No weather data available yet"}' + } + + // Unknown resource ID + return '{"error":"Resource not found: ' + req.id + '"}' +} diff --git a/examples/wasm-plugins/example-weather-plugin/build/plugin.d.ts b/examples/wasm-plugins/example-weather-plugin/build/plugin.d.ts new file mode 100644 index 000000000..ddd547a8d --- /dev/null +++ b/examples/wasm-plugins/example-weather-plugin/build/plugin.d.ts @@ -0,0 +1,45 @@ +/** Exported memory */ +export declare const memory: WebAssembly.Memory +// Exported runtime interface +export declare function __new(size: number, id: number): number +export declare function __pin(ptr: number): number +export declare function __unpin(ptr: number): void +export declare function __collect(): void +export declare const __rtti_base: number +/** + * assembly/index/plugin_name + * @returns `~lib/string/String` + */ +export declare function plugin_name(): string +/** + * assembly/index/plugin_schema + * @returns `~lib/string/String` + */ +export declare function plugin_schema(): string +/** + * assembly/index/plugin_start + * @param configPtr `usize` + * @param configLen `usize` + * @returns `i32` + */ +export declare function plugin_start( + configPtr: number, + configLen: number +): number +/** + * assembly/index/plugin_stop + * @returns `i32` + */ +export declare function plugin_stop(): number +/** + * assembly/index/resources_list_resources + * @param queryJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function resources_list_resources(queryJson: string): string +/** + * assembly/index/resources_get_resource + * @param requestJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function resources_get_resource(requestJson: string): string diff --git a/examples/wasm-plugins/example-weather-plugin/build/plugin.js b/examples/wasm-plugins/example-weather-plugin/build/plugin.js new file mode 100644 index 000000000..12d97175f --- /dev/null +++ b/examples/wasm-plugins/example-weather-plugin/build/plugin.js @@ -0,0 +1,198 @@ +import * as __import0 from 'as-fetch' +async function instantiate(module, imports = {}) { + const __module0 = imports['as-fetch'] + const adaptedImports = { + env: Object.assign(Object.create(globalThis), imports.env || {}, { + sk_debug(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_debug_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_debug(msgPtr, msgLen) + }, + sk_has_capability(capPtr, capLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/network/sk_has_capability_ffi(usize, usize) => i32 + capPtr = capPtr >>> 0 + capLen = capLen >>> 0 + return sk_has_capability(capPtr, capLen) + }, + sk_set_error(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_set_error_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_set_error(msgPtr, msgLen) + }, + sk_register_resource_provider(typePtr, typeLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/resources/sk_register_resource_provider_ffi(usize, usize) => i32 + typePtr = typePtr >>> 0 + typeLen = typeLen >>> 0 + return sk_register_resource_provider(typePtr, typeLen) + }, + sk_handle_message(deltaPtr, deltaLen, version) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_handle_message_ffi(usize, usize, i32) => void + deltaPtr = deltaPtr >>> 0 + deltaLen = deltaLen >>> 0 + sk_handle_message(deltaPtr, deltaLen, version) + }, + sk_set_status(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_set_status_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_set_status(msgPtr, msgLen) + } + }), + 'as-fetch': Object.assign(Object.create(__module0), { + _initAsyncify(asyncify_data_ptr, stack_pointer) { + // ~lib/as-fetch/sync/_initAsyncify(usize, usize) => void + asyncify_data_ptr = asyncify_data_ptr >>> 0 + stack_pointer = stack_pointer >>> 0 + __module0._initAsyncify(asyncify_data_ptr, stack_pointer) + }, + _fetchGETSync(url, mode, headers) { + // ~lib/as-fetch/sync/_fetchGETSync(~lib/string/String, i32, ~lib/array/Array<~lib/array/Array<~lib/string/String>>) => usize + url = __liftString(url >>> 0) + headers = __liftArray( + (pointer) => + __liftArray( + (pointer) => __liftString(__getU32(pointer)), + 2, + __getU32(pointer) + ), + 2, + headers >>> 0 + ) + return __module0._fetchGETSync(url, mode, headers) + }, + _fetchPOSTSync(url, mode, headers, body) { + // ~lib/as-fetch/sync/_fetchPOSTSync(~lib/string/String, i32, ~lib/array/Array<~lib/array/Array<~lib/string/String>>, ~lib/arraybuffer/ArrayBuffer) => usize + url = __liftString(url >>> 0) + headers = __liftArray( + (pointer) => + __liftArray( + (pointer) => __liftString(__getU32(pointer)), + 2, + __getU32(pointer) + ), + 2, + headers >>> 0 + ) + body = __liftBuffer(body >>> 0) + return __module0._fetchPOSTSync(url, mode, headers, body) + } + }) + } + const { exports } = await WebAssembly.instantiate(module, adaptedImports) + const memory = exports.memory || imports.env.memory + const adaptedExports = Object.setPrototypeOf( + { + plugin_name() { + // assembly/index/plugin_name() => ~lib/string/String + return __liftString(exports.plugin_name() >>> 0) + }, + plugin_schema() { + // assembly/index/plugin_schema() => ~lib/string/String + return __liftString(exports.plugin_schema() >>> 0) + }, + resources_list_resources(queryJson) { + // assembly/index/resources_list_resources(~lib/string/String) => ~lib/string/String + queryJson = __lowerString(queryJson) || __notnull() + return __liftString(exports.resources_list_resources(queryJson) >>> 0) + }, + resources_get_resource(requestJson) { + // assembly/index/resources_get_resource(~lib/string/String) => ~lib/string/String + requestJson = __lowerString(requestJson) || __notnull() + return __liftString(exports.resources_get_resource(requestJson) >>> 0) + } + }, + exports + ) + function __liftBuffer(pointer) { + if (!pointer) return null + return memory.buffer.slice( + pointer, + pointer + new Uint32Array(memory.buffer)[(pointer - 4) >>> 2] + ) + } + function __liftString(pointer) { + if (!pointer) return null + const end = + (pointer + new Uint32Array(memory.buffer)[(pointer - 4) >>> 2]) >>> 1, + memoryU16 = new Uint16Array(memory.buffer) + let start = pointer >>> 1, + string = '' + while (end - start > 1024) + string += String.fromCharCode( + ...memoryU16.subarray(start, (start += 1024)) + ) + return string + String.fromCharCode(...memoryU16.subarray(start, end)) + } + function __lowerString(value) { + if (value == null) return 0 + const length = value.length, + pointer = exports.__new(length << 1, 2) >>> 0, + memoryU16 = new Uint16Array(memory.buffer) + for (let i = 0; i < length; ++i) + memoryU16[(pointer >>> 1) + i] = value.charCodeAt(i) + return pointer + } + function __liftArray(liftElement, align, pointer) { + if (!pointer) return null + const dataStart = __getU32(pointer + 4), + length = __dataview.getUint32(pointer + 12, true), + values = new Array(length) + for (let i = 0; i < length; ++i) + values[i] = liftElement(dataStart + ((i << align) >>> 0)) + return values + } + function __notnull() { + throw TypeError('value must not be null') + } + let __dataview = new DataView(memory.buffer) + function __getU32(pointer) { + try { + return __dataview.getUint32(pointer, true) + } catch { + __dataview = new DataView(memory.buffer) + return __dataview.getUint32(pointer, true) + } + } + return adaptedExports +} +export const { + memory, + __new, + __pin, + __unpin, + __collect, + __rtti_base, + plugin_name, + plugin_schema, + plugin_start, + plugin_stop, + resources_list_resources, + resources_get_resource +} = await (async (url) => + instantiate( + await (async () => { + const isNodeOrBun = + typeof process != 'undefined' && + process.versions != null && + (process.versions.node != null || process.versions.bun != null) + if (isNodeOrBun) { + return globalThis.WebAssembly.compile( + await (await import('node:fs/promises')).readFile(url) + ) + } else { + return await globalThis.WebAssembly.compileStreaming( + globalThis.fetch(url) + ) + } + })(), + { + 'as-fetch': __maybeDefault(__import0) + } + ))(new URL('plugin.wasm', import.meta.url)) +function __maybeDefault(module) { + return typeof module.default === 'object' && Object.keys(module).length == 1 + ? module.default + : module +} diff --git a/examples/wasm-plugins/example-weather-plugin/package-lock.json b/examples/wasm-plugins/example-weather-plugin/package-lock.json new file mode 100644 index 000000000..d4f5b10db --- /dev/null +++ b/examples/wasm-plugins/example-weather-plugin/package-lock.json @@ -0,0 +1,249 @@ +{ + "name": "@signalk/weather-plugin-example", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@signalk/weather-plugin-example", + "version": "0.2.0", + "license": "Apache-2.0", + "dependencies": { + "as-fetch": "^2.1.4", + "signalk-assemblyscript-plugin-sdk": "^0.1.3" + }, + "devDependencies": { + "assemblyscript": "^0.27.0", + "rimraf": "^6.0.1" + } + }, + "../../../../signalk-assemblyscript-plugin-sdk": { + "version": "0.1.3", + "license": "Apache-2.0", + "dependencies": { + "as-fetch": "^2.1.4", + "as-wasi": "^0.6.0" + }, + "devDependencies": { + "assemblyscript": "^0.27.0" + } + }, + "../../../packages/assemblyscript-plugin-sdk": { + "name": "@signalk/assemblyscript-plugin-sdk", + "version": "0.1.0", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "as-fetch": "^2.1.4", + "as-wasi": "^0.6.0" + }, + "devDependencies": { + "assemblyscript": "^0.27.0" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/as-fetch": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/as-fetch/-/as-fetch-2.1.4.tgz", + "integrity": "sha512-/Qu8lMtNHQFyLxjryrzbkXbTndTGY0VztuittBjdAxc5DEdIad1hOvRrE3Q1ZlpOiRJH4fjT652Nl0r1oz5R1Q==", + "license": "MIT", + "dependencies": { + "json-as": "^0.5.52" + } + }, + "node_modules/as-string-sink": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/as-string-sink/-/as-string-sink-0.5.3.tgz", + "integrity": "sha512-DN/QqfptDhi4FaBqehMN9wH5Dl/PcTSfCfzSmcdtf3OVYYIEip5iyVUcS6t40BAP/OoUwtxCCbD/dlr2kVG+3Q==", + "license": "MIT" + }, + "node_modules/as-variant": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/as-variant/-/as-variant-0.4.1.tgz", + "integrity": "sha512-NCeerOfp1JRXRcT4WCqOBANPoDTE2zqS4eN+jPtdyNWJybUgG/rX9ugUxICkSQZzCfnG84zzxr3osQbjAkyiww==", + "license": "MIT" + }, + "node_modules/as-virtual": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/as-virtual/-/as-virtual-0.1.9.tgz", + "integrity": "sha512-R1nR7TT0KcROL/TxSXmiX2Q+7CgUMrjT/y9IP07StStqWs32KT2mpadJNF//yHWRaIJWe6atqTqO0JzsdhkPcQ==", + "license": "MIT" + }, + "node_modules/assemblyscript": { + "version": "0.27.37", + "resolved": "https://registry.npmjs.org/assemblyscript/-/assemblyscript-0.27.37.tgz", + "integrity": "sha512-YtY5k3PiV3SyUQ6gRlR2OCn8dcVRwkpiG/k2T5buoL2ymH/Z/YbaYWbk/f9mO2HTgEtGWjPiAQrIuvA7G/63Gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "binaryen": "116.0.0-nightly.20240114", + "long": "^5.2.4" + }, + "bin": { + "asc": "bin/asc.js", + "asinit": "bin/asinit.js" + }, + "engines": { + "node": ">=18", + "npm": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, + "node_modules/binaryen": { + "version": "116.0.0-nightly.20240114", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz", + "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-opt": "bin/wasm-opt", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/glob": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-as": { + "version": "0.5.67", + "resolved": "https://registry.npmjs.org/json-as/-/json-as-0.5.67.tgz", + "integrity": "sha512-DLIoK/JHUFYp9sn8XQ/Q2sM1b3dstt7XF/WTpwHI/6XUVuone7uIRKdiIxYWJcIPK1SinQsGg9MSLMBdUW2HlQ==", + "license": "MIT", + "dependencies": { + "as-string-sink": "^0.5.3", + "as-variant": "^0.4.1", + "as-virtual": "^0.1.9" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/signalk-assemblyscript-plugin-sdk": { + "resolved": "../../../../signalk-assemblyscript-plugin-sdk", + "link": true + } + } +} diff --git a/examples/wasm-plugins/example-weather-plugin/package.json b/examples/wasm-plugins/example-weather-plugin/package.json new file mode 100644 index 000000000..9055440ad --- /dev/null +++ b/examples/wasm-plugins/example-weather-plugin/package.json @@ -0,0 +1,41 @@ +{ + "name": "@signalk/example-weather-plugin", + "version": "0.2.0", + "description": "Example SignalK WASM plugin demonstrating network capability and resource provider", + "keywords": [ + "signalk-wasm-plugin", + "signalk", + "wasm", + "plugin", + "weather", + "resource-provider", + "example" + ], + "wasmManifest": "build/plugin.wasm", + "wasmCapabilities": { + "network": true, + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "serialPorts": false, + "resourceProvider": true + }, + "author": "Signal K Team", + "license": "Apache-2.0", + "scripts": { + "asbuild:debug": "asc assembly/index.ts --target debug --outFile build/plugin.debug.wasm", + "asbuild:release": "asc assembly/index.ts --target release --outFile build/plugin.wasm", + "asbuild": "npm run asbuild:release", + "build": "npm run asbuild", + "clean": "rimraf build/" + }, + "devDependencies": { + "assemblyscript": "^0.27.0", + "rimraf": "^6.0.1" + }, + "dependencies": { + "as-fetch": "^2.1.4", + "assemblyscript-json": "^1.1.0", + "@signalk/assemblyscript-plugin-sdk": "^0.2.0" + } +} diff --git a/examples/wasm-plugins/example-weather-provider/.npmignore b/examples/wasm-plugins/example-weather-provider/.npmignore new file mode 100644 index 000000000..88ae39394 --- /dev/null +++ b/examples/wasm-plugins/example-weather-provider/.npmignore @@ -0,0 +1,12 @@ +# Source files - not needed for runtime +assembly/ +asconfig.json + +# Dev/build artifacts +node_modules/ +*.tgz +*.debug.wasm + +# Generated JS files (keep only .wasm) +build/*.js +build/*.d.ts diff --git a/examples/wasm-plugins/example-weather-provider/README.md b/examples/wasm-plugins/example-weather-provider/README.md new file mode 100644 index 000000000..2c47ba704 --- /dev/null +++ b/examples/wasm-plugins/example-weather-provider/README.md @@ -0,0 +1,188 @@ +# Weather Provider Plugin Example + +This example demonstrates how to create a WASM plugin that integrates with Signal K's **Weather Provider API**. + +## What is a Weather Provider? + +Signal K has a specialized Weather API that provides standardized endpoints for weather data: + +``` +GET /signalk/v2/api/weather/observations?lat=...&lon=... +GET /signalk/v2/api/weather/forecasts/daily?lat=...&lon=... +GET /signalk/v2/api/weather/forecasts/point?lat=...&lon=... +GET /signalk/v2/api/weather/warnings?lat=...&lon=... +GET /signalk/v2/api/weather/_providers +``` + +This is different from the **Resource Provider** pattern (used by `weather-plugin`), which provides generic key-value storage at `/signalk/v2/api/resources/{type}`. + +## Features + +- Registers as a Signal K Weather Provider +- Fetches real weather data from OpenWeatherMap API +- Implements all three Weather Provider methods: + - `getObservations()` - Current weather conditions + - `getForecasts()` - Daily and point-in-time forecasts + - `getWarnings()` - Weather warnings/alerts +- Also emits weather data as Signal K deltas + +## Prerequisites + +- Node.js 18+ +- OpenWeatherMap API key (free tier works) + +## Building + +```bash +cd examples/wasm-plugins/example-weather-provider +npm install +npm run build +``` + +## Installation + +**Note:** The AssemblyScript Plugin SDK is not yet published to npm. Install it first - see [example-hello-assemblyscript](../example-hello-assemblyscript/README.md#installing-to-signal-k) for instructions. + +1. Build the plugin +2. Create installable package and install: + ```bash + npm pack + cd ~/.signalk + npm install /path/to/signalk-example-weather-provider-0.1.0.tgz + ``` +3. Restart Signal K server +4. Configure with your OpenWeatherMap API key + +## Configuration + +Configure the plugin via the Signal K Admin UI under **Server → Plugin Config**. You will need to provide your OpenWeatherMap API key. Configuration options are documented in the plugin's schema. + +## Testing + +Once configured and running, test the Weather API: + +```bash +# Get providers +curl http://localhost:3000/signalk/v2/api/weather/_providers + +# Get observations +curl "http://localhost:3000/signalk/v2/api/weather/observations?lat=60.17&lon=24.94" + +# Get daily forecast +curl "http://localhost:3000/signalk/v2/api/weather/forecasts/daily?lat=60.17&lon=24.94" + +# Get point forecast +curl "http://localhost:3000/signalk/v2/api/weather/forecasts/point?lat=60.17&lon=24.94" + +# Get warnings +curl "http://localhost:3000/signalk/v2/api/weather/warnings?lat=60.17&lon=24.94" +``` + +## Weather Provider vs Resource Provider + +| Feature | Weather Provider | Resource Provider | +| ---------- | ------------------------------------------ | ---------------------------------- | +| API Path | `/signalk/v2/api/weather/*` | `/signalk/v2/api/resources/{type}` | +| Methods | getObservations, getForecasts, getWarnings | list, get, set, delete | +| Use Case | Standardized weather data | Generic data storage | +| Capability | `weatherProvider: true` | `resourceProvider: true` | +| FFI | `sk_register_weather_provider` | `sk_register_resource_provider` | + +## Implementation Details + +### Capability Declaration + +In `package.json`: + +```json +{ + "wasmCapabilities": { + "weatherProvider": true, + "network": true + } +} +``` + +### Registration + +In plugin `start()`: + +```typescript +@external("env", "sk_register_weather_provider") +declare function sk_register_weather_provider(namePtr: usize, nameLen: usize): i32 + +// Register with provider name +registerWeatherProvider('OpenWeatherMap WASM') +``` + +### Handler Exports + +The plugin must export these functions: + +```typescript +// GET /signalk/v2/api/weather/observations +export function weather_get_observations(requestJson: string): string + +// GET /signalk/v2/api/weather/forecasts/{type} +export function weather_get_forecasts(requestJson: string): string + +// GET /signalk/v2/api/weather/warnings +export function weather_get_warnings(requestJson: string): string +``` + +### Request Format + +```json +{ + "position": { + "latitude": 60.17, + "longitude": 24.94 + }, + "type": "daily", + "options": { + "maxCount": 7 + } +} +``` + +### Response Format (Observations/Forecasts) + +```json +[ + { + "date": "2025-12-05T10:00:00.000Z", + "type": "observation", + "description": "light rain", + "outside": { + "temperature": 275.15, + "relativeHumidity": 0.85, + "pressure": 101300, + "cloudCover": 0.75 + }, + "wind": { + "speedTrue": 5.2, + "directionTrue": 3.14 + } + } +] +``` + +### Response Format (Warnings) + +```json +[ + { + "startTime": "2025-12-05T10:00:00.000Z", + "endTime": "2025-12-05T18:00:00.000Z", + "details": "Strong wind warning", + "source": "OpenWeatherMap", + "type": "Warning" + } +] +``` + +## See Also + +- [example-weather-plugin](../example-weather-plugin/) - Resource Provider example +- [WASM Developer Guide](../../../docs/develop/plugins/wasm/README.md) +- [Signal K Weather API](https://signalk.org/specification/1.7.0/doc/weather.html) diff --git a/examples/wasm-plugins/example-weather-provider/asconfig.json b/examples/wasm-plugins/example-weather-provider/asconfig.json new file mode 100644 index 000000000..028a34968 --- /dev/null +++ b/examples/wasm-plugins/example-weather-provider/asconfig.json @@ -0,0 +1,25 @@ +{ + "targets": { + "release": { + "outFile": "build/plugin.wasm", + "sourceMap": false, + "optimize": true, + "shrinkLevel": 2, + "converge": true, + "noAssert": true, + "runtime": "stub", + "use": "abort=" + }, + "debug": { + "outFile": "build/plugin.debug.wasm", + "sourceMap": true, + "debug": true, + "runtime": "stub" + } + }, + "options": { + "bindings": "esm", + "exportRuntime": true, + "transform": ["as-fetch/transform"] + } +} diff --git a/examples/wasm-plugins/example-weather-provider/assembly/index.ts b/examples/wasm-plugins/example-weather-provider/assembly/index.ts new file mode 100644 index 000000000..7ee7ce96a --- /dev/null +++ b/examples/wasm-plugins/example-weather-provider/assembly/index.ts @@ -0,0 +1,523 @@ +/** + * Weather Provider Plugin Example for Signal K + * + * Demonstrates: + * - Weather Provider API integration (Signal K's official weather API) + * - Provides observations, forecasts, and warnings via /signalk/v2/api/weather + * - Fetches real weather data from OpenWeatherMap API + * + * This is different from the Resource Provider pattern - Weather Provider + * integrates with Signal K's specialized weather API at: + * GET /signalk/v2/api/weather/observations?lat=...&lon=... + * GET /signalk/v2/api/weather/forecasts/daily?lat=...&lon=... + * GET /signalk/v2/api/weather/forecasts/point?lat=...&lon=... + * GET /signalk/v2/api/weather/warnings?lat=...&lon=... + */ + +import { + Plugin, + emit, + setStatus, + setError, + debug, + createSimpleDelta +} from '@signalk/assemblyscript-plugin-sdk/assembly' + +import { + hasNetworkCapability +} from '@signalk/assemblyscript-plugin-sdk/assembly/network' + +import { fetchSync } from 'as-fetch/sync' + +// ===== Weather Provider FFI ===== +// Declare the host-provided function for registering as a weather provider +@external("env", "sk_register_weather_provider") +declare function sk_register_weather_provider(namePtr: usize, nameLen: usize): i32 + +/** + * Register this plugin as a weather provider + * @param providerName - Display name for this provider (e.g., "OpenWeatherMap") + * @returns true if registration succeeded + */ +function registerWeatherProvider(providerName: string): bool { + const nameBytes = String.UTF8.encode(providerName) + const result = sk_register_weather_provider( + changetype(nameBytes), + nameBytes.byteLength + ) + return result === 1 +} + +// ===== Weather Data Types ===== + +/** + * Weather observation/forecast data structure + * Aligned with Signal K Weather API specification + */ +class WeatherData { + date: string = '' + type: string = 'observation' // 'observation' | 'daily' | 'point' + description: string = '' + + // Outside conditions + temperature: f64 = 0.0 // Kelvin + minTemperature: f64 = 0.0 // Kelvin (for daily forecasts) + maxTemperature: f64 = 0.0 // Kelvin (for daily forecasts) + feelsLikeTemperature: f64 = 0.0 // Kelvin + humidity: f64 = 0.0 // Ratio (0-1) + pressure: f64 = 0.0 // Pascals + dewPointTemperature: f64 = 0.0 // Kelvin + cloudCover: f64 = 0.0 // Ratio (0-1) + uvIndex: f64 = 0.0 + + // Wind + windSpeedTrue: f64 = 0.0 // m/s + windDirectionTrue: f64 = 0.0 // Radians + windGust: f64 = 0.0 // m/s + + // Precipitation + precipitationVolume: f64 = 0.0 // mm + + toJSON(): string { + let json = '{"date":"' + this.date + '"' + json += ',"type":"' + this.type + '"' + + if (this.description.length > 0) { + json += ',"description":"' + this.description + '"' + } + + json += ',"outside":{' + json += '"temperature":' + this.temperature.toString() + + if (this.type === 'daily') { + json += ',"minTemperature":' + this.minTemperature.toString() + json += ',"maxTemperature":' + this.maxTemperature.toString() + } + + if (this.feelsLikeTemperature > 0) { + json += ',"feelsLikeTemperature":' + this.feelsLikeTemperature.toString() + } + json += ',"relativeHumidity":' + this.humidity.toString() + json += ',"pressure":' + this.pressure.toString() + json += ',"cloudCover":' + this.cloudCover.toString() + json += '}' + + json += ',"wind":{' + json += '"speedTrue":' + this.windSpeedTrue.toString() + json += ',"directionTrue":' + this.windDirectionTrue.toString() + if (this.windGust > 0) { + json += ',"gust":' + this.windGust.toString() + } + json += '}' + + json += '}' + return json + } +} + +/** + * Weather warning data structure + */ +class WeatherWarning { + startTime: string = '' + endTime: string = '' + details: string = '' + source: string = 'OpenWeatherMap' + type: string = 'Warning' + + toJSON(): string { + return '{"startTime":"' + this.startTime + '"' + + ',"endTime":"' + this.endTime + '"' + + ',"details":"' + this.details + '"' + + ',"source":"' + this.source + '"' + + ',"type":"' + this.type + '"}' + } +} + +// ===== Configuration ===== + +class WeatherConfig { + apiKey: string = '' + defaultLatitude: f64 = 60.1699 // Helsinki + defaultLongitude: f64 = 24.9384 +} + +// ===== Global State ===== + +let config: WeatherConfig = new WeatherConfig() +let lastObservation: WeatherData | null = null + +// ===== JSON Parsing Helpers ===== + +function extractNumber(json: string, key: string): f64 { + const match = json.indexOf('"' + key + '":') + if (match < 0) return 0.0 + + const start = match + key.length + 3 + let end = start + while (end < json.length && + (json.charCodeAt(end) >= 48 && json.charCodeAt(end) <= 57 || + json.charCodeAt(end) === 46 || + json.charCodeAt(end) === 45)) { + end++ + } + if (end > start) { + return parseFloat(json.substring(start, end)) + } + return 0.0 +} + +function extractString(json: string, key: string): string { + const match = json.indexOf('"' + key + '":"') + if (match < 0) return '' + + const start = match + key.length + 4 + const end = json.indexOf('"', start) + if (end > start) { + return json.substring(start, end) + } + return '' +} + +// ===== Weather API Functions ===== + +function fetchCurrentWeather(lat: f64, lon: f64): WeatherData | null { + const url = 'https://api.openweathermap.org/data/2.5/weather?lat=' + + lat.toString() + '&lon=' + lon.toString() + + '&appid=' + config.apiKey + + debug('Fetching current weather from: ' + url) + + const response = fetchSync(url) + if (!response.ok) { + debug('Weather fetch failed: HTTP ' + response.status.toString()) + return null + } + + const json = response.text() + debug('Weather response: ' + json.substring(0, 200)) + + const data = new WeatherData() + data.date = '' // Server will provide timestamp + data.type = 'observation' + + // Parse main weather data + data.temperature = extractNumber(json, 'temp') + data.feelsLikeTemperature = extractNumber(json, 'feels_like') + data.humidity = extractNumber(json, 'humidity') / 100.0 + data.pressure = extractNumber(json, 'pressure') * 100.0 // hPa to Pa + + // Parse wind + data.windSpeedTrue = extractNumber(json, 'speed') + const windDeg = extractNumber(json, 'deg') + data.windDirectionTrue = windDeg * 3.14159265359 / 180.0 + + // Parse clouds + data.cloudCover = extractNumber(json, 'all') / 100.0 + + // Parse description + data.description = extractString(json, 'description') + + return data +} + +function fetchForecast(lat: f64, lon: f64, forecastType: string): WeatherData[] { + // OpenWeatherMap One Call API would be better for forecasts + // For this example, we'll use the basic forecast API + const url = 'https://api.openweathermap.org/data/2.5/forecast?lat=' + + lat.toString() + '&lon=' + lon.toString() + + '&appid=' + config.apiKey + + debug('Fetching forecast from: ' + url) + + const response = fetchSync(url) + if (!response.ok) { + debug('Forecast fetch failed: HTTP ' + response.status.toString()) + return [] + } + + const json = response.text() + + // Parse forecast list - simplified parsing + // In production, use proper JSON parsing + const forecasts: WeatherData[] = [] + + // For this example, create mock forecast data based on current conditions + // A real implementation would parse the OpenWeatherMap response + const data = new WeatherData() + data.date = '' // Server will provide timestamp + data.type = forecastType + data.temperature = extractNumber(json, 'temp') + data.humidity = extractNumber(json, 'humidity') / 100.0 + data.pressure = extractNumber(json, 'pressure') * 100.0 + data.windSpeedTrue = extractNumber(json, 'speed') + const windDeg = extractNumber(json, 'deg') + data.windDirectionTrue = windDeg * 3.14159265359 / 180.0 + data.description = extractString(json, 'description') + + if (forecastType === 'daily') { + data.minTemperature = data.temperature - 5.0 + data.maxTemperature = data.temperature + 5.0 + } + + forecasts.push(data) + + return forecasts +} + +// ===== Plugin Class ===== + +class WeatherProviderPlugin extends Plugin { + // Note: Plugin ID is derived from package.json name + + name(): string { + return 'Weather Provider Plugin (Example)' + } + + start(configJson: string): i32 { + debug('Weather Provider plugin starting...') + + // Check network capability + if (!hasNetworkCapability()) { + setError('Network capability not granted') + return 1 + } + + // Parse configuration + if (configJson.length > 2) { + // Extract API key + const apiKeyMatch = configJson.indexOf('"apiKey"') + if (apiKeyMatch >= 0) { + const colonPos = configJson.indexOf(':', apiKeyMatch) + const quoteStart = configJson.indexOf('"', colonPos) + if (quoteStart >= 0) { + const keyStart = quoteStart + 1 + const keyEnd = configJson.indexOf('"', keyStart) + if (keyEnd > keyStart) { + config.apiKey = configJson.substring(keyStart, keyEnd) + } + } + } + + // Extract default latitude + const latMatch = configJson.indexOf('"defaultLatitude"') + if (latMatch >= 0) { + config.defaultLatitude = extractNumber(configJson, 'defaultLatitude') + } + + // Extract default longitude + const lonMatch = configJson.indexOf('"defaultLongitude"') + if (lonMatch >= 0) { + config.defaultLongitude = extractNumber(configJson, 'defaultLongitude') + } + } + + // Validate configuration + if (config.apiKey.length === 0) { + setError('No API key configured - get one from https://openweathermap.org/api') + return 1 + } + + // Register as a weather provider + debug('Registering as weather provider...') + if (registerWeatherProvider('OpenWeatherMap WASM')) { + debug('Successfully registered as weather provider') + } else { + debug('Warning: Failed to register as weather provider') + setError('Failed to register as weather provider - capability may not be granted') + return 1 + } + + // Fetch initial weather data + lastObservation = fetchCurrentWeather(config.defaultLatitude, config.defaultLongitude) + if (lastObservation !== null) { + // Also emit as deltas for real-time display + const obs = lastObservation as WeatherData + emit(createSimpleDelta('environment.outside.temperature', obs.temperature.toString())) + emit(createSimpleDelta('environment.outside.humidity', obs.humidity.toString())) + emit(createSimpleDelta('environment.outside.pressure', obs.pressure.toString())) + } + + setStatus('Weather provider running - data from OpenWeatherMap') + return 0 + } + + stop(): i32 { + debug('Weather Provider plugin stopped') + setStatus('Stopped') + return 0 + } + + schema(): string { + return `{ + "type": "object", + "required": ["apiKey"], + "properties": { + "apiKey": { + "type": "string", + "title": "OpenWeatherMap API Key", + "description": "Get your free API key from https://openweathermap.org/api" + }, + "defaultLatitude": { + "type": "number", + "title": "Default Latitude", + "description": "Default latitude when not specified in request", + "default": 60.1699 + }, + "defaultLongitude": { + "type": "number", + "title": "Default Longitude", + "description": "Default longitude when not specified in request", + "default": 24.9384 + } + } + }` + } +} + +// ===== Plugin Instance & Exports ===== + +const plugin = new WeatherProviderPlugin() + +// Note: plugin_id() is no longer required - ID is derived from package.json name + +export function plugin_name(): string { + return plugin.name() +} + +export function plugin_schema(): string { + return plugin.schema() +} + +export function plugin_start(configPtr: usize, configLen: usize): i32 { + const len = i32(configLen) + const configBytes = new Uint8Array(len) + for (let i: i32 = 0; i < len; i++) { + configBytes[i] = load(configPtr + i) + } + const configJson = String.UTF8.decode(configBytes.buffer) + return plugin.start(configJson) +} + +export function plugin_stop(): i32 { + return plugin.stop() +} + +// ===== Weather Provider Handler Exports ===== +// These are called by the Signal K server when requests come to /signalk/v2/api/weather + +/** + * Request structure for weather queries + */ +class WeatherRequest { + latitude: f64 = 0.0 + longitude: f64 = 0.0 + type: string = '' + maxCount: i32 = 10 + + static parse(json: string): WeatherRequest { + const req = new WeatherRequest() + + // Parse position + const posMatch = json.indexOf('"position"') + if (posMatch >= 0) { + req.latitude = extractNumber(json, 'latitude') + req.longitude = extractNumber(json, 'longitude') + } + + // Parse type + req.type = extractString(json, 'type') + + // Parse options + const countMatch = json.indexOf('"maxCount"') + if (countMatch >= 0) { + req.maxCount = i32(extractNumber(json, 'maxCount')) + } + + return req + } +} + +/** + * Get weather observations for a location + * Called for: GET /signalk/v2/api/weather/observations?lat=...&lon=... + * + * @param requestJson - JSON with { "position": { "latitude": ..., "longitude": ... }, "options": {...} } + * @returns JSON array of observation data + */ +export function weather_get_observations(requestJson: string): string { + debug('weather_get_observations called: ' + requestJson) + + const req = WeatherRequest.parse(requestJson) + + // Use provided position or default + const lat = req.latitude !== 0.0 ? req.latitude : config.defaultLatitude + const lon = req.longitude !== 0.0 ? req.longitude : config.defaultLongitude + + // Fetch current weather as observation + const observation = fetchCurrentWeather(lat, lon) + if (observation === null) { + return '[]' + } + + // Cache it + lastObservation = observation + + // Return as array + return '[' + observation.toJSON() + ']' +} + +/** + * Get weather forecasts for a location + * Called for: GET /signalk/v2/api/weather/forecasts/daily?lat=...&lon=... + * GET /signalk/v2/api/weather/forecasts/point?lat=...&lon=... + * + * @param requestJson - JSON with position, type ('daily'|'point'), and options + * @returns JSON array of forecast data + */ +export function weather_get_forecasts(requestJson: string): string { + debug('weather_get_forecasts called: ' + requestJson) + + const req = WeatherRequest.parse(requestJson) + + // Use provided position or default + const lat = req.latitude !== 0.0 ? req.latitude : config.defaultLatitude + const lon = req.longitude !== 0.0 ? req.longitude : config.defaultLongitude + + // Determine forecast type + const forecastType = req.type.length > 0 ? req.type : 'point' + + // Fetch forecasts + const forecasts = fetchForecast(lat, lon, forecastType) + + if (forecasts.length === 0) { + return '[]' + } + + // Build JSON array + let result = '[' + for (let i = 0; i < forecasts.length; i++) { + if (i > 0) result += ',' + result += forecasts[i].toJSON() + } + result += ']' + + return result +} + +/** + * Get weather warnings for a location + * Called for: GET /signalk/v2/api/weather/warnings?lat=...&lon=... + * + * @param requestJson - JSON with position + * @returns JSON array of warning data + */ +export function weather_get_warnings(requestJson: string): string { + debug('weather_get_warnings called: ' + requestJson) + + // OpenWeatherMap's free tier doesn't include weather alerts + // Return empty array - real implementation would use One Call API + // which requires a paid subscription for alerts + + return '[]' +} diff --git a/examples/wasm-plugins/example-weather-provider/build/plugin.d.ts b/examples/wasm-plugins/example-weather-provider/build/plugin.d.ts new file mode 100644 index 000000000..dd57ff894 --- /dev/null +++ b/examples/wasm-plugins/example-weather-provider/build/plugin.d.ts @@ -0,0 +1,51 @@ +/** Exported memory */ +export declare const memory: WebAssembly.Memory +// Exported runtime interface +export declare function __new(size: number, id: number): number +export declare function __pin(ptr: number): number +export declare function __unpin(ptr: number): void +export declare function __collect(): void +export declare const __rtti_base: number +/** + * assembly/index/plugin_name + * @returns `~lib/string/String` + */ +export declare function plugin_name(): string +/** + * assembly/index/plugin_schema + * @returns `~lib/string/String` + */ +export declare function plugin_schema(): string +/** + * assembly/index/plugin_start + * @param configPtr `usize` + * @param configLen `usize` + * @returns `i32` + */ +export declare function plugin_start( + configPtr: number, + configLen: number +): number +/** + * assembly/index/plugin_stop + * @returns `i32` + */ +export declare function plugin_stop(): number +/** + * assembly/index/weather_get_observations + * @param requestJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function weather_get_observations(requestJson: string): string +/** + * assembly/index/weather_get_forecasts + * @param requestJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function weather_get_forecasts(requestJson: string): string +/** + * assembly/index/weather_get_warnings + * @param requestJson `~lib/string/String` + * @returns `~lib/string/String` + */ +export declare function weather_get_warnings(requestJson: string): string diff --git a/examples/wasm-plugins/example-weather-provider/build/plugin.js b/examples/wasm-plugins/example-weather-provider/build/plugin.js new file mode 100644 index 000000000..9abe01902 --- /dev/null +++ b/examples/wasm-plugins/example-weather-provider/build/plugin.js @@ -0,0 +1,204 @@ +import * as __import0 from 'as-fetch' +async function instantiate(module, imports = {}) { + const __module0 = imports['as-fetch'] + const adaptedImports = { + env: Object.assign(Object.create(globalThis), imports.env || {}, { + sk_debug(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_debug_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_debug(msgPtr, msgLen) + }, + sk_has_capability(capPtr, capLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/network/sk_has_capability_ffi(usize, usize) => i32 + capPtr = capPtr >>> 0 + capLen = capLen >>> 0 + return sk_has_capability(capPtr, capLen) + }, + sk_set_error(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_set_error_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_set_error(msgPtr, msgLen) + }, + sk_register_weather_provider(namePtr, nameLen) { + // assembly/index/sk_register_weather_provider(usize, usize) => i32 + namePtr = namePtr >>> 0 + nameLen = nameLen >>> 0 + return sk_register_weather_provider(namePtr, nameLen) + }, + sk_handle_message(deltaPtr, deltaLen, version) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_handle_message_ffi(usize, usize, i32) => void + deltaPtr = deltaPtr >>> 0 + deltaLen = deltaLen >>> 0 + sk_handle_message(deltaPtr, deltaLen, version) + }, + sk_set_status(msgPtr, msgLen) { + // ~lib/@signalk/assemblyscript-plugin-sdk/assembly/api/sk_set_status_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0 + msgLen = msgLen >>> 0 + sk_set_status(msgPtr, msgLen) + } + }), + 'as-fetch': Object.assign(Object.create(__module0), { + _initAsyncify(asyncify_data_ptr, stack_pointer) { + // ~lib/as-fetch/sync/_initAsyncify(usize, usize) => void + asyncify_data_ptr = asyncify_data_ptr >>> 0 + stack_pointer = stack_pointer >>> 0 + __module0._initAsyncify(asyncify_data_ptr, stack_pointer) + }, + _fetchGETSync(url, mode, headers) { + // ~lib/as-fetch/sync/_fetchGETSync(~lib/string/String, i32, ~lib/array/Array<~lib/array/Array<~lib/string/String>>) => usize + url = __liftString(url >>> 0) + headers = __liftArray( + (pointer) => + __liftArray( + (pointer) => __liftString(__getU32(pointer)), + 2, + __getU32(pointer) + ), + 2, + headers >>> 0 + ) + return __module0._fetchGETSync(url, mode, headers) + }, + _fetchPOSTSync(url, mode, headers, body) { + // ~lib/as-fetch/sync/_fetchPOSTSync(~lib/string/String, i32, ~lib/array/Array<~lib/array/Array<~lib/string/String>>, ~lib/arraybuffer/ArrayBuffer) => usize + url = __liftString(url >>> 0) + headers = __liftArray( + (pointer) => + __liftArray( + (pointer) => __liftString(__getU32(pointer)), + 2, + __getU32(pointer) + ), + 2, + headers >>> 0 + ) + body = __liftBuffer(body >>> 0) + return __module0._fetchPOSTSync(url, mode, headers, body) + } + }) + } + const { exports } = await WebAssembly.instantiate(module, adaptedImports) + const memory = exports.memory || imports.env.memory + const adaptedExports = Object.setPrototypeOf( + { + plugin_name() { + // assembly/index/plugin_name() => ~lib/string/String + return __liftString(exports.plugin_name() >>> 0) + }, + plugin_schema() { + // assembly/index/plugin_schema() => ~lib/string/String + return __liftString(exports.plugin_schema() >>> 0) + }, + weather_get_observations(requestJson) { + // assembly/index/weather_get_observations(~lib/string/String) => ~lib/string/String + requestJson = __lowerString(requestJson) || __notnull() + return __liftString(exports.weather_get_observations(requestJson) >>> 0) + }, + weather_get_forecasts(requestJson) { + // assembly/index/weather_get_forecasts(~lib/string/String) => ~lib/string/String + requestJson = __lowerString(requestJson) || __notnull() + return __liftString(exports.weather_get_forecasts(requestJson) >>> 0) + }, + weather_get_warnings(requestJson) { + // assembly/index/weather_get_warnings(~lib/string/String) => ~lib/string/String + requestJson = __lowerString(requestJson) || __notnull() + return __liftString(exports.weather_get_warnings(requestJson) >>> 0) + } + }, + exports + ) + function __liftBuffer(pointer) { + if (!pointer) return null + return memory.buffer.slice( + pointer, + pointer + new Uint32Array(memory.buffer)[(pointer - 4) >>> 2] + ) + } + function __liftString(pointer) { + if (!pointer) return null + const end = + (pointer + new Uint32Array(memory.buffer)[(pointer - 4) >>> 2]) >>> 1, + memoryU16 = new Uint16Array(memory.buffer) + let start = pointer >>> 1, + string = '' + while (end - start > 1024) + string += String.fromCharCode( + ...memoryU16.subarray(start, (start += 1024)) + ) + return string + String.fromCharCode(...memoryU16.subarray(start, end)) + } + function __lowerString(value) { + if (value == null) return 0 + const length = value.length, + pointer = exports.__new(length << 1, 2) >>> 0, + memoryU16 = new Uint16Array(memory.buffer) + for (let i = 0; i < length; ++i) + memoryU16[(pointer >>> 1) + i] = value.charCodeAt(i) + return pointer + } + function __liftArray(liftElement, align, pointer) { + if (!pointer) return null + const dataStart = __getU32(pointer + 4), + length = __dataview.getUint32(pointer + 12, true), + values = new Array(length) + for (let i = 0; i < length; ++i) + values[i] = liftElement(dataStart + ((i << align) >>> 0)) + return values + } + function __notnull() { + throw TypeError('value must not be null') + } + let __dataview = new DataView(memory.buffer) + function __getU32(pointer) { + try { + return __dataview.getUint32(pointer, true) + } catch { + __dataview = new DataView(memory.buffer) + return __dataview.getUint32(pointer, true) + } + } + return adaptedExports +} +export const { + memory, + __new, + __pin, + __unpin, + __collect, + __rtti_base, + plugin_name, + plugin_schema, + plugin_start, + plugin_stop, + weather_get_observations, + weather_get_forecasts, + weather_get_warnings +} = await (async (url) => + instantiate( + await (async () => { + const isNodeOrBun = + typeof process != 'undefined' && + process.versions != null && + (process.versions.node != null || process.versions.bun != null) + if (isNodeOrBun) { + return globalThis.WebAssembly.compile( + await (await import('node:fs/promises')).readFile(url) + ) + } else { + return await globalThis.WebAssembly.compileStreaming( + globalThis.fetch(url) + ) + } + })(), + { + 'as-fetch': __maybeDefault(__import0) + } + ))(new URL('plugin.wasm', import.meta.url)) +function __maybeDefault(module) { + return typeof module.default === 'object' && Object.keys(module).length == 1 + ? module.default + : module +} diff --git a/examples/wasm-plugins/example-weather-provider/package-lock.json b/examples/wasm-plugins/example-weather-provider/package-lock.json new file mode 100644 index 000000000..611f111e1 --- /dev/null +++ b/examples/wasm-plugins/example-weather-provider/package-lock.json @@ -0,0 +1,249 @@ +{ + "name": "@signalk/weather-provider-plugin-example", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@signalk/weather-provider-plugin-example", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "as-fetch": "^2.1.4", + "signalk-assemblyscript-plugin-sdk": "^0.1.3" + }, + "devDependencies": { + "assemblyscript": "^0.27.0", + "rimraf": "^6.0.1" + } + }, + "node_modules/@assemblyscript/wasi-shim": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@assemblyscript/wasi-shim/-/wasi-shim-0.1.0.tgz", + "integrity": "sha512-fSLH7MdJHf2uDW5llA5VCF/CG62Jp2WkYGui9/3vIWs3jDhViGeQF7nMYLUjpigluM5fnq61I6obtCETy39FZw==", + "license": "Apache-2.0", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/as-fetch": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/as-fetch/-/as-fetch-2.1.4.tgz", + "integrity": "sha512-/Qu8lMtNHQFyLxjryrzbkXbTndTGY0VztuittBjdAxc5DEdIad1hOvRrE3Q1ZlpOiRJH4fjT652Nl0r1oz5R1Q==", + "license": "MIT", + "dependencies": { + "json-as": "^0.5.52" + } + }, + "node_modules/as-string-sink": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/as-string-sink/-/as-string-sink-0.5.3.tgz", + "integrity": "sha512-DN/QqfptDhi4FaBqehMN9wH5Dl/PcTSfCfzSmcdtf3OVYYIEip5iyVUcS6t40BAP/OoUwtxCCbD/dlr2kVG+3Q==", + "license": "MIT" + }, + "node_modules/as-variant": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/as-variant/-/as-variant-0.4.1.tgz", + "integrity": "sha512-NCeerOfp1JRXRcT4WCqOBANPoDTE2zqS4eN+jPtdyNWJybUgG/rX9ugUxICkSQZzCfnG84zzxr3osQbjAkyiww==", + "license": "MIT" + }, + "node_modules/as-virtual": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/as-virtual/-/as-virtual-0.1.9.tgz", + "integrity": "sha512-R1nR7TT0KcROL/TxSXmiX2Q+7CgUMrjT/y9IP07StStqWs32KT2mpadJNF//yHWRaIJWe6atqTqO0JzsdhkPcQ==", + "license": "MIT" + }, + "node_modules/as-wasi": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/as-wasi/-/as-wasi-0.6.0.tgz", + "integrity": "sha512-2EHzHhkdnDrgSuO6a1Lm0k/DIxjeupBUpDmli1xpB74y9ArQuvEunc1LqyPT4yfx10lTyaYkzDmelBAcasQH6g==", + "dependencies": { + "@assemblyscript/wasi-shim": "^0.1" + } + }, + "node_modules/assemblyscript": { + "version": "0.27.37", + "resolved": "https://registry.npmjs.org/assemblyscript/-/assemblyscript-0.27.37.tgz", + "integrity": "sha512-YtY5k3PiV3SyUQ6gRlR2OCn8dcVRwkpiG/k2T5buoL2ymH/Z/YbaYWbk/f9mO2HTgEtGWjPiAQrIuvA7G/63Gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "binaryen": "116.0.0-nightly.20240114", + "long": "^5.2.4" + }, + "bin": { + "asc": "bin/asc.js", + "asinit": "bin/asinit.js" + }, + "engines": { + "node": ">=18", + "npm": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, + "node_modules/binaryen": { + "version": "116.0.0-nightly.20240114", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-116.0.0-nightly.20240114.tgz", + "integrity": "sha512-0GZrojJnuhoe+hiwji7QFaL3tBlJoA+KFUN7ouYSDGZLSo9CKM8swQX8n/UcbR0d1VuZKU+nhogNzv423JEu5A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-opt": "bin/wasm-opt", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/glob": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-as": { + "version": "0.5.67", + "resolved": "https://registry.npmjs.org/json-as/-/json-as-0.5.67.tgz", + "integrity": "sha512-DLIoK/JHUFYp9sn8XQ/Q2sM1b3dstt7XF/WTpwHI/6XUVuone7uIRKdiIxYWJcIPK1SinQsGg9MSLMBdUW2HlQ==", + "license": "MIT", + "dependencies": { + "as-string-sink": "^0.5.3", + "as-variant": "^0.4.1", + "as-virtual": "^0.1.9" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/signalk-assemblyscript-plugin-sdk": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/signalk-assemblyscript-plugin-sdk/-/signalk-assemblyscript-plugin-sdk-0.1.3.tgz", + "integrity": "sha512-jfI1dLzMPGLPt9+BEO2rcb1cDyiHvR55d7ueNqP9Lq8TAF1LQrOLV3niC999n3XoK+t+xJdqkAn+x9IW71KhdA==", + "license": "Apache-2.0", + "dependencies": { + "as-fetch": "^2.1.4", + "as-wasi": "^0.6.0" + } + } + } +} diff --git a/examples/wasm-plugins/example-weather-provider/package.json b/examples/wasm-plugins/example-weather-provider/package.json new file mode 100644 index 000000000..e7a4d614d --- /dev/null +++ b/examples/wasm-plugins/example-weather-provider/package.json @@ -0,0 +1,40 @@ +{ + "name": "@signalk/example-weather-provider", + "version": "0.1.0", + "description": "Example SignalK WASM plugin demonstrating Weather Provider API integration", + "keywords": [ + "signalk-wasm-plugin", + "signalk", + "wasm", + "plugin", + "weather", + "weather-provider", + "example" + ], + "wasmManifest": "build/plugin.wasm", + "wasmCapabilities": { + "network": true, + "storage": "vfs-only", + "dataRead": true, + "dataWrite": true, + "serialPorts": false, + "weatherProvider": true + }, + "author": "Signal K Team", + "license": "Apache-2.0", + "scripts": { + "asbuild:debug": "asc assembly/index.ts --target debug --outFile build/plugin.debug.wasm", + "asbuild:release": "asc assembly/index.ts --target release --outFile build/plugin.wasm", + "asbuild": "npm run asbuild:release", + "build": "npm run asbuild", + "clean": "rimraf build/" + }, + "devDependencies": { + "assemblyscript": "^0.27.0", + "rimraf": "^6.0.1" + }, + "dependencies": { + "as-fetch": "^2.1.4", + "@signalk/assemblyscript-plugin-sdk": "^0.2.0" + } +} diff --git a/package.json b/package.json index b0b37ee81..dd00bc904 100644 --- a/package.json +++ b/package.json @@ -66,19 +66,23 @@ "packages/streams", "packages/server-api", "packages/resources-provider-plugin", - "packages/typedoc-theme" + "packages/typedoc-theme", + "packages/assemblyscript-plugin-sdk" ], "dependencies": { - "@js-temporal/polyfill": "^0.5.1", + "@assemblyscript/loader": "^0.28.9", + "@bytecodealliance/jco": "^1.4.0", "@signalk/course-provider": "^1.0.0", "@signalk/n2k-signalk": ">=4.1.0-beta", "@signalk/nmea0183-signalk": "^3.0.0", "@signalk/resources-provider": "^1.5.1", "@signalk/server-admin-ui": "2.19.x", - "@signalk/server-api": "2.10.x", + "@signalk/server-api": "^2.10.2", "@signalk/signalk-schema": "^1.7.1", "@signalk/streams": "5.1.x", + "@wasmer/wasi": "^1.2.2", "api-schema-builder": "^2.0.11", + "as-fetch": "^2.1.4", "baconjs": "^1.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.14.1", @@ -100,6 +104,7 @@ "express-rate-limit": "^8.2.1", "figlet": "^1.2.0", "file-timestamp-stream": "^2.1.2", + "fix": "^0.0.6", "geolib": "3.2.2", "helmet": "^8.1.0", "inquirer": "^7.0.0", @@ -136,11 +141,14 @@ "signalk-n2kais-to-nmea0183": "^1.3.1", "signalk-to-nmea2000": "^2.16.0" }, + "bundledDependencies": [ + "@signalk/server-api" + ], "devDependencies": { "@eslint-react/eslint-plugin": "^1.45.1", "@eslint/js": "^9.24.0", "@signalk/typedoc-signalk-theme": "^0.3.0", - "@tsconfig/node20": "^20.1.5", + "@tsconfig/node20": "^20.1.8", "@types/baconjs": "^0.7.34", "@types/busboy": "^1.5.0", "@types/chai": "^4.2.15", @@ -156,6 +164,7 @@ "@types/swagger-ui-express": "^4.1.3", "@types/unzipper": "^0.10.5", "@types/uuid": "^8.3.1", + "@types/ws": "^8.18.1", "chai": "^4.3.0", "chai-json-equal": "0.0.1", "chai-things": "^0.2.0", @@ -175,5 +184,9 @@ "typescript": "^5.8.2", "typescript-eslint": "^8.29.1" }, - "funding": "https://opencollective.com/signalk" + "funding": "https://opencollective.com/signalk", + "bundleDependencies": [ + "@signalk/server-api", + "@signalk/server-admin-ui" + ] } diff --git a/packages/assemblyscript-plugin-sdk/.npmignore b/packages/assemblyscript-plugin-sdk/.npmignore new file mode 100644 index 000000000..08b25532d --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/.npmignore @@ -0,0 +1 @@ +node_modules diff --git a/packages/assemblyscript-plugin-sdk/LICENSE b/packages/assemblyscript-plugin-sdk/LICENSE new file mode 100644 index 000000000..90e80c370 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [dirkwa] [Dirk Wahrheit] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/assemblyscript-plugin-sdk/README.md b/packages/assemblyscript-plugin-sdk/README.md new file mode 100644 index 000000000..0ae9a8fe1 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/README.md @@ -0,0 +1,40 @@ +# Signal K AssemblyScript Plugin SDK + +Build WASM plugins for Signal K Server using TypeScript-like syntax. + +## Features + +- TypeScript-like syntax (strict subset) +- Compiles directly to WASM +- Small binaries (3-10 KB typical) +- Good performance (80-90% of Rust) +- Familiar tooling (npm, TypeScript) + +## Installation + +```bash +npm install @signalk/assemblyscript-plugin-sdk +npm install --save-dev assemblyscript +``` + +## Documentation + +For complete documentation including: + +- Step-by-step tutorial +- API reference +- Resource providers +- Troubleshooting + +See the [AssemblyScript Plugin Guide](../../docs/develop/plugins/wasm/assemblyscript.md) in the Signal K Server documentation. + +## Examples + +See [examples/wasm-plugins/](../../examples/wasm-plugins/) for working examples: + +- `example-hello-assemblyscript` - Basic plugin +- `example-weather-plugin` - Resource provider with network requests + +## License + +Apache-2.0 diff --git a/packages/assemblyscript-plugin-sdk/asconfig.json b/packages/assemblyscript-plugin-sdk/asconfig.json new file mode 100644 index 000000000..053cf7733 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/asconfig.json @@ -0,0 +1,25 @@ +{ + "targets": { + "release": { + "outFile": "build/plugin.wasm", + "sourceMap": false, + "optimize": true, + "shrinkLevel": 2, + "converge": true, + "noAssert": true, + "runtime": "stub", + "use": "abort=" + }, + "debug": { + "outFile": "build/plugin.debug.wasm", + "sourceMap": true, + "debug": true, + "runtime": "stub" + } + }, + "options": { + "bindings": "esm", + "exportRuntime": false, + "transform": ["as-fetch/transform"] + } +} diff --git a/packages/assemblyscript-plugin-sdk/assembly/api.ts b/packages/assemblyscript-plugin-sdk/assembly/api.ts new file mode 100644 index 000000000..e00a913c5 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/assembly/api.ts @@ -0,0 +1,287 @@ +/** + * Signal K Server API functions for AssemblyScript plugins + * + * These functions provide the FFI bridge to the Signal K server + */ + +import { Delta } from './signalk' + +// ===== FFI Declarations ===== +// These functions are provided by the Signal K server + +/** + * @internal + * Emit delta to Signal K server + * @param deltaPtr - Pointer to delta JSON string + * @param deltaLen - Length of delta JSON string + * @param version - Signal K version: 0 = v1 (default), 1 = v2 + */ +@external("env", "sk_handle_message") +declare function sk_handle_message_ffi(deltaPtr: usize, deltaLen: usize, version: i32): void + +/** + * @internal + * Set plugin status message + */ +@external("env", "sk_set_status") +declare function sk_set_status_ffi(msgPtr: usize, msgLen: usize): void + +/** + * @internal + * Set plugin error message + */ +@external("env", "sk_set_error") +declare function sk_set_error_ffi(msgPtr: usize, msgLen: usize): void + +/** + * @internal + * Debug logging + */ +@external("env", "sk_debug") +declare function sk_debug_ffi(msgPtr: usize, msgLen: usize): void + +/** + * @internal + * Get value from vessel.self path + */ +@external("env", "sk_get_self_path") +declare function sk_get_self_path_ffi( + pathPtr: usize, + pathLen: usize, + bufPtr: usize, + bufLen: usize +): i32 + +/** + * @internal + * Get value from any context path + */ +@external("env", "sk_get_path") +declare function sk_get_path_ffi( + pathPtr: usize, + pathLen: usize, + bufPtr: usize, + bufLen: usize +): i32 + +/** + * @internal + * Read plugin configuration + */ +@external("env", "sk_read_config") +declare function sk_read_config_ffi(bufPtr: usize, bufLen: usize): i32 + +/** + * @internal + * Save plugin configuration + */ +@external("env", "sk_save_config") +declare function sk_save_config_ffi(configPtr: usize, configLen: usize): i32 + +// ===== Public API Functions ===== + +/** + * Signal K version for delta emission + */ +export const SK_VERSION_V1: i32 = 1 +export const SK_VERSION_V2: i32 = 2 + +/** + * Emit a delta message to the Signal K server + * + * @param delta The delta message to emit + * @param skVersion Signal K version: SK_VERSION_V1 (default) or SK_VERSION_V2 + * + * Use SK_VERSION_V1 (default) for regular navigation data. + * Use SK_VERSION_V2 for Course API paths and other v2-specific data to prevent + * v2 data from being mixed into the v1 full data model. + * + * @example + * ```typescript + * // Emit v1 delta (default - for regular navigation data) + * const delta = createSimpleDelta('my-plugin', 'environment.temperature', '25.5') + * emit(delta) + * + * // Emit v2 delta (for Course API and v2-specific paths) + * const courseDelta = createSimpleDelta('my-plugin', 'navigation.course.nextPoint', positionJson) + * emit(courseDelta, SK_VERSION_V2) + * ``` + */ +export function emit(delta: Delta, skVersion: i32 = SK_VERSION_V1): void { + const json = delta.toJSON() + const buffer = String.UTF8.encode(json) + const ptr = changetype(buffer) + sk_handle_message_ffi(ptr, buffer.byteLength, skVersion) +} + +/** + * Set plugin status message (shown in admin UI) + * + * @param message Status message + * + * @example + * ```typescript + * setStatus('Running normally') + * ``` + */ +export function setStatus(message: string): void { + const buffer = String.UTF8.encode(message) + const ptr = changetype(buffer) + sk_set_status_ffi(ptr, buffer.byteLength) +} + +/** + * Set plugin error message (shown in admin UI) + * + * @param message Error message + * + * @example + * ```typescript + * setError('Failed to connect to sensor') + * ``` + */ +export function setError(message: string): void { + const buffer = String.UTF8.encode(message) + const ptr = changetype(buffer) + sk_set_error_ffi(ptr, buffer.byteLength) +} + +/** + * Log debug message to server logs + * + * @param message Debug message + * + * @example + * ```typescript + * debug('Processing data: ' + value.toString()) + * ``` + */ +export function debug(message: string): void { + const buffer = String.UTF8.encode(message) + const ptr = changetype(buffer) + sk_debug_ffi(ptr, buffer.byteLength) +} + +/** + * Get value from vessel.self path + * + * @param path Signal K path (e.g., 'navigation.speedOverGround') + * @returns JSON-encoded value or null if not found + * + * @example + * ```typescript + * const speedJson = getSelfPath('navigation.speedOverGround') + * if (speedJson !== null) { + * const speed = parseFloat(speedJson) + * debug('Current speed: ' + speed.toString()) + * } + * ``` + */ +export function getSelfPath(path: string): string | null { + const pathBuffer = String.UTF8.encode(path) + const pathPtr = changetype(pathBuffer) + + // Allocate buffer for result + const resultBuffer = new ArrayBuffer(1024) + const resultPtr = changetype(resultBuffer) + + const len = sk_get_self_path_ffi( + pathPtr, + pathBuffer.byteLength, + resultPtr, + 1024 + ) + + if (len === 0) { + return null + } + + // Decode result + const bytes = Uint8Array.wrap(resultBuffer, 0, len) + return String.UTF8.decode(bytes.buffer) +} + +/** + * Get value from any context path + * + * @param path Full Signal K path (e.g., 'vessels.urn:mrn:imo:mmsi:123456789.navigation.position') + * @returns JSON-encoded value or null if not found + * + * @example + * ```typescript + * const posJson = getPath('vessels.self.navigation.position') + * if (posJson !== null) { + * debug('Position: ' + posJson) + * } + * ``` + */ +export function getPath(path: string): string | null { + const pathBuffer = String.UTF8.encode(path) + const pathPtr = changetype(pathBuffer) + + // Allocate buffer for result + const resultBuffer = new ArrayBuffer(1024) + const resultPtr = changetype(resultBuffer) + + const len = sk_get_path_ffi( + pathPtr, + pathBuffer.byteLength, + resultPtr, + 1024 + ) + + if (len === 0) { + return null + } + + // Decode result + const bytes = Uint8Array.wrap(resultBuffer, 0, len) + return String.UTF8.decode(bytes.buffer) +} + +/** + * Read plugin configuration + * + * @returns JSON string with configuration + * + * @example + * ```typescript + * const configJson = readConfig() + * const config = JSON.parse(configJson) + * ``` + */ +export function readConfig(): string { + // Allocate buffer for result + const resultBuffer = new ArrayBuffer(4096) + const resultPtr = changetype(resultBuffer) + + const len = sk_read_config_ffi(resultPtr, 4096) + + if (len === 0) { + return '{}' + } + + // Decode result + const bytes = Uint8Array.wrap(resultBuffer, 0, len) + return String.UTF8.decode(bytes.buffer) +} + +/** + * Save plugin configuration + * + * @param config Configuration object (will be JSON-serialized) + * @returns 0 on success, non-zero on error + * + * @example + * ```typescript + * const result = saveConfig(JSON.stringify(myConfig)) + * if (result !== 0) { + * setError('Failed to save configuration') + * } + * ``` + */ +export function saveConfig(configJson: string): i32 { + const buffer = String.UTF8.encode(configJson) + const ptr = changetype(buffer) + return sk_save_config_ffi(ptr, buffer.byteLength) +} diff --git a/packages/assemblyscript-plugin-sdk/assembly/index.ts b/packages/assemblyscript-plugin-sdk/assembly/index.ts new file mode 100644 index 000000000..3306e8b06 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/assembly/index.ts @@ -0,0 +1,15 @@ +/** + * Signal K AssemblyScript Plugin SDK + * + * Provides TypeScript-like API for building WASM plugins + */ + +// Export all public types and functions +export * from './plugin' +export * from './signalk' +export * from './api' +export * from './network' +export * from './resources' + +// Re-export JSON parsing library for plugin authors +export { JSON } from 'assemblyscript-json/assembly' diff --git a/packages/assemblyscript-plugin-sdk/assembly/network.ts b/packages/assemblyscript-plugin-sdk/assembly/network.ts new file mode 100644 index 000000000..5458ce044 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/assembly/network.ts @@ -0,0 +1,53 @@ +/** + * Network API for AssemblyScript plugins + * + * Provides capability checking for network access + * Requires 'network' capability in plugin manifest + * + * For HTTP requests, use as-fetch directly: + * + * @example + * ```typescript + * import { fetchSync } from 'as-fetch/sync' + * import { Response } from 'as-fetch/assembly' + * import { hasNetworkCapability } from 'signalk-assemblyscript-plugin-sdk' + * + * if (!hasNetworkCapability()) { + * setError('Network capability not granted') + * return 1 + * } + * + * const response = fetchSync('https://api.example.com/data') + * if (response && response.status === 200) { + * const data = response.text() + * // Process data... + * } + * ``` + */ + +/** + * @internal + * Check if network capability is granted + */ +@external("env", "sk_has_capability") +declare function sk_has_capability_ffi(capPtr: usize, capLen: usize): i32 + +/** + * Check if network capability is available + * + * @returns true if plugin has network capability + * + * @example + * ```typescript + * if (!hasNetworkCapability()) { + * setError('Network capability not granted') + * return 1 + * } + * ``` + */ +export function hasNetworkCapability(): boolean { + const capName = 'network' + const buffer = String.UTF8.encode(capName) + const ptr = changetype(buffer) + return sk_has_capability_ffi(ptr, buffer.byteLength) === 1 +} diff --git a/packages/assemblyscript-plugin-sdk/assembly/plugin.ts b/packages/assemblyscript-plugin-sdk/assembly/plugin.ts new file mode 100644 index 000000000..f9caf7f90 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/assembly/plugin.ts @@ -0,0 +1,49 @@ +/** + * Base Plugin class that all AssemblyScript plugins must extend + */ + +/** + * Abstract base class for Signal K WASM plugins + * + * Plugins must implement these abstract methods: + * - name(): Human-readable plugin name + * - schema(): JSON schema for configuration UI + * - start(): Initialize plugin with configuration + * - stop(): Clean shutdown + * + * Note: Plugin ID is automatically derived from your package.json name. + * For example: "@signalk/example-weather-plugin" → "_signalk_example-weather-plugin" + */ +export abstract class Plugin { + /** + * Return human-readable plugin name for display in Admin UI + */ + abstract name(): string + + /** + * Return JSON schema for configuration UI + * Must be valid JSON Schema draft-07 + */ + abstract schema(): string + + /** + * Initialize plugin with configuration + * @param config JSON string with configuration + * @returns 0 for success, non-zero for error + */ + abstract start(config: string): i32 + + /** + * Stop plugin and clean up resources + * @returns 0 for success, non-zero for error + */ + abstract stop(): i32 +} + +/** + * Plugin configuration interface + * Extend this for type-safe configuration + */ +export class PluginConfig { + enabled: bool = true +} diff --git a/packages/assemblyscript-plugin-sdk/assembly/resources.ts b/packages/assemblyscript-plugin-sdk/assembly/resources.ts new file mode 100644 index 000000000..403ab5c72 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/assembly/resources.ts @@ -0,0 +1,157 @@ +/** + * Signal K Resource Provider API for AssemblyScript plugins + * + * Allows WASM plugins to act as resource providers (routes, waypoints, weather, etc.) + */ + +import { JSON } from 'assemblyscript-json/assembly' + +// ===== FFI Declarations ===== + +/** + * @internal + * Register this plugin as a resource provider for a given type + */ +@external("env", "sk_register_resource_provider") +declare function sk_register_resource_provider_ffi(typePtr: usize, typeLen: usize): i32 + +// ===== Public API Functions ===== + +/** + * Register this plugin as a resource provider for a given resource type. + * + * After calling this, the plugin must export the following handler functions: + * - resources_list_resources(queryJson: string): string - List resources matching query + * - resources_get_resource(requestJson: string): string - Get a single resource + * - resources_set_resource(requestJson: string): void - Create/update a resource + * - resources_delete_resource(requestJson: string): void - Delete a resource + * + * @param resourceType The type of resources to provide (e.g., "weather", "routes", "waypoints") + * @returns true if registration succeeded, false otherwise + * + * @example + * ```typescript + * import { registerResourceProvider } from 'signalk-assemblyscript-plugin-sdk/assembly/resources' + * + * // In plugin start(): + * if (!registerResourceProvider("weather-forecasts")) { + * setError("Failed to register as resource provider") + * return 1 + * } + * + * // Export handler functions: + * export function resources_list_resources(queryJson: string): string { + * // Return JSON object of resources + * return '{"forecast-1": {"name": "Current Weather"}}' + * } + * + * export function resources_get_resource(requestJson: string): string { + * // requestJson: {"id": "forecast-1", "property": null} + * return '{"name": "Current Weather", "temperature": 20.5}' + * } + * ``` + */ +export function registerResourceProvider(resourceType: string): bool { + const buffer = String.UTF8.encode(resourceType) + const ptr = changetype(buffer) + const result = sk_register_resource_provider_ffi(ptr, buffer.byteLength) + return result === 1 +} + +/** + * Check if this plugin has the resourceProvider capability granted. + * + * The capability must be declared in package.json: + * ```json + * { + * "wasmCapabilities": { + * "resourceProvider": true + * } + * } + * ``` + * + * @returns true if resourceProvider capability is granted + */ +export function hasResourceProviderCapability(): bool { + // Try to check capability via sk_has_capability if available + // For now, we'll just return true and let registration fail if not granted + return true +} + +// ===== Helper Types for Resource Handlers ===== + +/** + * Request format for resources_get_resource handler + */ +export class ResourceGetRequest { + id: string = '' + property: string | null = null + + static parse(jsonStr: string): ResourceGetRequest { + const req = new ResourceGetRequest() + const parsed = JSON.parse(jsonStr) + + if (parsed.isObj) { + const obj = parsed as JSON.Obj + const idValue = obj.getString('id') + if (idValue !== null) { + req.id = idValue.valueOf() + } + const propValue = obj.getString('property') + if (propValue !== null) { + req.property = propValue.valueOf() + } + } + + return req + } +} + +/** + * Request format for resources_set_resource handler + */ +export class ResourceSetRequest { + id: string = '' + value: string = '{}' // JSON string of the value + + static parse(jsonStr: string): ResourceSetRequest { + const req = new ResourceSetRequest() + const parsed = JSON.parse(jsonStr) + + if (parsed.isObj) { + const obj = parsed as JSON.Obj + const idValue = obj.getString('id') + if (idValue !== null) { + req.id = idValue.valueOf() + } + const valueObj = obj.getObj('value') + if (valueObj !== null) { + req.value = valueObj.stringify() + } + } + + return req + } +} + +/** + * Request format for resources_delete_resource handler + */ +export class ResourceDeleteRequest { + id: string = '' + + static parse(jsonStr: string): ResourceDeleteRequest { + const req = new ResourceDeleteRequest() + const parsed = JSON.parse(jsonStr) + + if (parsed.isObj) { + const obj = parsed as JSON.Obj + const idValue = obj.getString('id') + if (idValue !== null) { + req.id = idValue.valueOf() + } + } + + return req + } +} diff --git a/packages/assemblyscript-plugin-sdk/assembly/signalk.ts b/packages/assemblyscript-plugin-sdk/assembly/signalk.ts new file mode 100644 index 000000000..dd02b9d9d --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/assembly/signalk.ts @@ -0,0 +1,153 @@ +/** + * Signal K data model types for AssemblyScript + */ + +/** + * Position with latitude and longitude + */ +export class Position { + latitude: f64 + longitude: f64 + + constructor(latitude: f64, longitude: f64) { + this.latitude = latitude + this.longitude = longitude + } + + toJSON(): string { + return `{"latitude":${this.latitude},"longitude":${this.longitude}}` + } +} + +/** + * Path-value pair for delta updates + */ +export class PathValue { + path: string + value: string // JSON-encoded value + + constructor(path: string, value: string) { + this.path = path + this.value = value + } + + toJSON(): string { + return `{"path":"${this.path}","value":${this.value}}` + } +} + +/** + * Delta update containing values + * + * Note: Plugins should NOT include source or timestamp when emitting deltas. + * The server automatically sets $source to the plugin ID and fills in + * timestamp with the current time. + */ +export class Update { + values: PathValue[] + + constructor(values: PathValue[]) { + this.values = values + } + + toJSON(): string { + let valuesJson = '[' + for (let i = 0; i < this.values.length; i++) { + if (i > 0) valuesJson += ',' + valuesJson += this.values[i].toJSON() + } + valuesJson += ']' + + return `{"values":${valuesJson}}` + } +} + +/** + * Delta message with context and updates + */ +export class Delta { + context: string + updates: Update[] + + constructor(context: string, updates: Update[]) { + this.context = context + this.updates = updates + } + + toJSON(): string { + let updatesJson = '[' + for (let i = 0; i < this.updates.length; i++) { + if (i > 0) updatesJson += ',' + updatesJson += this.updates[i].toJSON() + } + updatesJson += ']' + + return `{"context":"${this.context}","updates":${updatesJson}}` + } +} + +/** + * Notification state + */ +export enum NotificationState { + normal = 0, + alert = 1, + warn = 2, + alarm = 3, + emergency = 4 +} + +/** + * Notification method + */ +export enum NotificationMethod { + visual = 0, + sound = 1 +} + +/** + * Signal K notification + */ +export class Notification { + state: NotificationState + method: NotificationMethod[] + message: string + + constructor(state: NotificationState, message: string) { + this.state = state + this.method = [NotificationMethod.visual] + this.message = message + } + + toJSON(): string { + let methodStr = '[' + for (let i = 0; i < this.method.length; i++) { + if (i > 0) methodStr += ',' + methodStr += this.method[i] == NotificationMethod.visual ? '"visual"' : '"sound"' + } + methodStr += ']' + + let stateStr = 'normal' + if (this.state == NotificationState.alert) stateStr = 'alert' + else if (this.state == NotificationState.warn) stateStr = 'warn' + else if (this.state == NotificationState.alarm) stateStr = 'alarm' + else if (this.state == NotificationState.emergency) stateStr = 'emergency' + + return `{"state":"${stateStr}","method":${methodStr},"message":"${this.message}"}` + } +} + +/** + * Helper to create a simple delta with single value + * + * The server automatically adds $source (plugin ID) and timestamp. + * Plugins should not include these fields. + * + * @param path Signal K path (e.g., 'environment.outside.temperature') + * @param value JSON-encoded value (e.g., '288.15' or '{"latitude":60,"longitude":24}') + */ +export function createSimpleDelta(path: string, value: string): Delta { + const pathValue = new PathValue(path, value) + const update = new Update([pathValue]) + return new Delta('vessels.self', [update]) +} diff --git a/packages/assemblyscript-plugin-sdk/build/plugin.d.ts b/packages/assemblyscript-plugin-sdk/build/plugin.d.ts new file mode 100644 index 000000000..8001f4549 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/build/plugin.d.ts @@ -0,0 +1,104 @@ +/** Exported memory */ +export declare const memory: WebAssembly.Memory; +/** assembly/signalk/NotificationState */ +export declare enum NotificationState { + /** @type `i32` */ + normal, + /** @type `i32` */ + alert, + /** @type `i32` */ + warn, + /** @type `i32` */ + alarm, + /** @type `i32` */ + emergency, +} +/** assembly/signalk/NotificationMethod */ +export declare enum NotificationMethod { + /** @type `i32` */ + visual, + /** @type `i32` */ + sound, +} +/** + * assembly/signalk/createSimpleDelta + * @param path `~lib/string/String` + * @param value `~lib/string/String` + * @returns `assembly/signalk/Delta` + */ +export declare function createSimpleDelta(path: string, value: string): __Internref4; +/** assembly/api/SK_VERSION_V1 */ +export declare const SK_VERSION_V1: { + /** @type `i32` */ + get value(): number +}; +/** assembly/api/SK_VERSION_V2 */ +export declare const SK_VERSION_V2: { + /** @type `i32` */ + get value(): number +}; +/** + * assembly/api/emit + * @param delta `assembly/signalk/Delta` + * @param skVersion `i32` + */ +export declare function emit(delta: __Internref4, skVersion?: number): void; +/** + * assembly/api/setStatus + * @param message `~lib/string/String` + */ +export declare function setStatus(message: string): void; +/** + * assembly/api/setError + * @param message `~lib/string/String` + */ +export declare function setError(message: string): void; +/** + * assembly/api/debug + * @param message `~lib/string/String` + */ +export declare function debug(message: string): void; +/** + * assembly/api/getSelfPath + * @param path `~lib/string/String` + * @returns `~lib/string/String | null` + */ +export declare function getSelfPath(path: string): string | null; +/** + * assembly/api/getPath + * @param path `~lib/string/String` + * @returns `~lib/string/String | null` + */ +export declare function getPath(path: string): string | null; +/** + * assembly/api/readConfig + * @returns `~lib/string/String` + */ +export declare function readConfig(): string; +/** + * assembly/api/saveConfig + * @param configJson `~lib/string/String` + * @returns `i32` + */ +export declare function saveConfig(configJson: string): number; +/** + * assembly/network/hasNetworkCapability + * @returns `bool` + */ +export declare function hasNetworkCapability(): boolean; +/** + * assembly/resources/registerResourceProvider + * @param resourceType `~lib/string/String` + * @returns `bool` + */ +export declare function registerResourceProvider(resourceType: string): boolean; +/** + * assembly/resources/hasResourceProviderCapability + * @returns `bool` + */ +export declare function hasResourceProviderCapability(): boolean; +/** assembly/signalk/Delta */ +declare class __Internref4 extends Number { + private __nominal4: symbol; + private __nominal0: symbol; +} diff --git a/packages/assemblyscript-plugin-sdk/build/plugin.js b/packages/assemblyscript-plugin-sdk/build/plugin.js new file mode 100644 index 000000000..cf05ffad4 --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/build/plugin.js @@ -0,0 +1,232 @@ +async function instantiate(module, imports = {}) { + const adaptedImports = { + env: Object.assign(Object.create(globalThis), imports.env || {}, { + sk_handle_message(deltaPtr, deltaLen, version) { + // assembly/api/sk_handle_message_ffi(usize, usize, i32) => void + deltaPtr = deltaPtr >>> 0; + deltaLen = deltaLen >>> 0; + sk_handle_message(deltaPtr, deltaLen, version); + }, + sk_set_status(msgPtr, msgLen) { + // assembly/api/sk_set_status_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0; + msgLen = msgLen >>> 0; + sk_set_status(msgPtr, msgLen); + }, + sk_set_error(msgPtr, msgLen) { + // assembly/api/sk_set_error_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0; + msgLen = msgLen >>> 0; + sk_set_error(msgPtr, msgLen); + }, + sk_debug(msgPtr, msgLen) { + // assembly/api/sk_debug_ffi(usize, usize) => void + msgPtr = msgPtr >>> 0; + msgLen = msgLen >>> 0; + sk_debug(msgPtr, msgLen); + }, + sk_get_self_path(pathPtr, pathLen, bufPtr, bufLen) { + // assembly/api/sk_get_self_path_ffi(usize, usize, usize, usize) => i32 + pathPtr = pathPtr >>> 0; + pathLen = pathLen >>> 0; + bufPtr = bufPtr >>> 0; + bufLen = bufLen >>> 0; + return sk_get_self_path(pathPtr, pathLen, bufPtr, bufLen); + }, + sk_get_path(pathPtr, pathLen, bufPtr, bufLen) { + // assembly/api/sk_get_path_ffi(usize, usize, usize, usize) => i32 + pathPtr = pathPtr >>> 0; + pathLen = pathLen >>> 0; + bufPtr = bufPtr >>> 0; + bufLen = bufLen >>> 0; + return sk_get_path(pathPtr, pathLen, bufPtr, bufLen); + }, + sk_read_config(bufPtr, bufLen) { + // assembly/api/sk_read_config_ffi(usize, usize) => i32 + bufPtr = bufPtr >>> 0; + bufLen = bufLen >>> 0; + return sk_read_config(bufPtr, bufLen); + }, + sk_save_config(configPtr, configLen) { + // assembly/api/sk_save_config_ffi(usize, usize) => i32 + configPtr = configPtr >>> 0; + configLen = configLen >>> 0; + return sk_save_config(configPtr, configLen); + }, + sk_has_capability(capPtr, capLen) { + // assembly/network/sk_has_capability_ffi(usize, usize) => i32 + capPtr = capPtr >>> 0; + capLen = capLen >>> 0; + return sk_has_capability(capPtr, capLen); + }, + sk_register_resource_provider(typePtr, typeLen) { + // assembly/resources/sk_register_resource_provider_ffi(usize, usize) => i32 + typePtr = typePtr >>> 0; + typeLen = typeLen >>> 0; + return sk_register_resource_provider(typePtr, typeLen); + }, + }), + }; + const { exports } = await WebAssembly.instantiate(module, adaptedImports); + const memory = exports.memory || imports.env.memory; + const adaptedExports = Object.setPrototypeOf({ + NotificationState: (values => ( + // assembly/signalk/NotificationState + values[values.normal = exports["NotificationState.normal"].valueOf()] = "normal", + values[values.alert = exports["NotificationState.alert"].valueOf()] = "alert", + values[values.warn = exports["NotificationState.warn"].valueOf()] = "warn", + values[values.alarm = exports["NotificationState.alarm"].valueOf()] = "alarm", + values[values.emergency = exports["NotificationState.emergency"].valueOf()] = "emergency", + values + ))({}), + NotificationMethod: (values => ( + // assembly/signalk/NotificationMethod + values[values.visual = exports["NotificationMethod.visual"].valueOf()] = "visual", + values[values.sound = exports["NotificationMethod.sound"].valueOf()] = "sound", + values + ))({}), + createSimpleDelta(path, value) { + // assembly/signalk/createSimpleDelta(~lib/string/String, ~lib/string/String) => assembly/signalk/Delta + path = __retain(__lowerString(path) || __notnull()); + value = __lowerString(value) || __notnull(); + try { + return __liftInternref(exports.createSimpleDelta(path, value) >>> 0); + } finally { + __release(path); + } + }, + emit(delta, skVersion) { + // assembly/api/emit(assembly/signalk/Delta, i32?) => void + delta = __lowerInternref(delta) || __notnull(); + exports.__setArgumentsLength(arguments.length); + exports.emit(delta, skVersion); + }, + setStatus(message) { + // assembly/api/setStatus(~lib/string/String) => void + message = __lowerString(message) || __notnull(); + exports.setStatus(message); + }, + setError(message) { + // assembly/api/setError(~lib/string/String) => void + message = __lowerString(message) || __notnull(); + exports.setError(message); + }, + debug(message) { + // assembly/api/debug(~lib/string/String) => void + message = __lowerString(message) || __notnull(); + exports.debug(message); + }, + getSelfPath(path) { + // assembly/api/getSelfPath(~lib/string/String) => ~lib/string/String | null + path = __lowerString(path) || __notnull(); + return __liftString(exports.getSelfPath(path) >>> 0); + }, + getPath(path) { + // assembly/api/getPath(~lib/string/String) => ~lib/string/String | null + path = __lowerString(path) || __notnull(); + return __liftString(exports.getPath(path) >>> 0); + }, + readConfig() { + // assembly/api/readConfig() => ~lib/string/String + return __liftString(exports.readConfig() >>> 0); + }, + saveConfig(configJson) { + // assembly/api/saveConfig(~lib/string/String) => i32 + configJson = __lowerString(configJson) || __notnull(); + return exports.saveConfig(configJson); + }, + hasNetworkCapability() { + // assembly/network/hasNetworkCapability() => bool + return exports.hasNetworkCapability() != 0; + }, + registerResourceProvider(resourceType) { + // assembly/resources/registerResourceProvider(~lib/string/String) => bool + resourceType = __lowerString(resourceType) || __notnull(); + return exports.registerResourceProvider(resourceType) != 0; + }, + hasResourceProviderCapability() { + // assembly/resources/hasResourceProviderCapability() => bool + return exports.hasResourceProviderCapability() != 0; + }, + }, exports); + function __liftString(pointer) { + if (!pointer) return null; + const + end = pointer + new Uint32Array(memory.buffer)[pointer - 4 >>> 2] >>> 1, + memoryU16 = new Uint16Array(memory.buffer); + let + start = pointer >>> 1, + string = ""; + while (end - start > 1024) string += String.fromCharCode(...memoryU16.subarray(start, start += 1024)); + return string + String.fromCharCode(...memoryU16.subarray(start, end)); + } + function __lowerString(value) { + if (value == null) return 0; + const + length = value.length, + pointer = exports.__new(length << 1, 2) >>> 0, + memoryU16 = new Uint16Array(memory.buffer); + for (let i = 0; i < length; ++i) memoryU16[(pointer >>> 1) + i] = value.charCodeAt(i); + return pointer; + } + class Internref extends Number {} + const registry = new FinalizationRegistry(__release); + function __liftInternref(pointer) { + if (!pointer) return null; + const sentinel = new Internref(__retain(pointer)); + registry.register(sentinel, pointer); + return sentinel; + } + function __lowerInternref(value) { + if (value == null) return 0; + if (value instanceof Internref) return value.valueOf(); + throw TypeError("internref expected"); + } + const refcounts = new Map(); + function __retain(pointer) { + if (pointer) { + const refcount = refcounts.get(pointer); + if (refcount) refcounts.set(pointer, refcount + 1); + else refcounts.set(exports.__pin(pointer), 1); + } + return pointer; + } + function __release(pointer) { + if (pointer) { + const refcount = refcounts.get(pointer); + if (refcount === 1) exports.__unpin(pointer), refcounts.delete(pointer); + else if (refcount) refcounts.set(pointer, refcount - 1); + else throw Error(`invalid refcount '${refcount}' for reference '${pointer}'`); + } + } + function __notnull() { + throw TypeError("value must not be null"); + } + return adaptedExports; +} +export const { + memory, + NotificationState, + NotificationMethod, + createSimpleDelta, + SK_VERSION_V1, + SK_VERSION_V2, + emit, + setStatus, + setError, + debug, + getSelfPath, + getPath, + readConfig, + saveConfig, + hasNetworkCapability, + registerResourceProvider, + hasResourceProviderCapability, +} = await (async url => instantiate( + await (async () => { + const isNodeOrBun = typeof process != "undefined" && process.versions != null && (process.versions.node != null || process.versions.bun != null); + if (isNodeOrBun) { return globalThis.WebAssembly.compile(await (await import("node:fs/promises")).readFile(url)); } + else { return await globalThis.WebAssembly.compileStreaming(globalThis.fetch(url)); } + })(), { + } +))(new URL("plugin.wasm", import.meta.url)); diff --git a/packages/assemblyscript-plugin-sdk/package.json b/packages/assemblyscript-plugin-sdk/package.json new file mode 100644 index 000000000..2c40091ae --- /dev/null +++ b/packages/assemblyscript-plugin-sdk/package.json @@ -0,0 +1,51 @@ +{ + "name": "@signalk/assemblyscript-plugin-sdk", + "version": "0.2.0", + "description": "AssemblyScript SDK for developing Signal K WASM plugins", + "main": "assembly/index.ts", + "types": "index.d.ts", + "exports": { + ".": { + "import": "./assembly/index.ts", + "types": "./index.d.ts" + }, + "./assembly": { + "import": "./assembly/index.ts" + }, + "./assembly/*": { + "import": "./assembly/*.ts" + } + }, + "ascMain": "assembly/index.ts", + "keywords": [ + "signalk", + "assemblyscript", + "wasm", + "plugin", + "sdk" + ], + "author": "Signal K", + "license": "Apache-2.0", + "scripts": { + "asbuild:debug": "asc assembly/index.ts --target debug --disableWarning 235", + "asbuild:release": "asc assembly/index.ts --target release --disableWarning 235", + "build": "npm run asbuild:release", + "test": "npm run asbuild:debug" + }, + "devDependencies": { + "assemblyscript": "^0.27.0" + }, + "dependencies": { + "as-wasi": "^0.6.0", + "as-fetch": "^2.1.4", + "assemblyscript-json": "^1.1.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SignalK/signalk-server.git", + "directory": "packages/assemblyscript-plugin-sdk" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/server-admin-ui/src/views/Configuration/Configuration.js b/packages/server-admin-ui/src/views/Configuration/Configuration.js index 2afed7186..1d2e19102 100644 --- a/packages/server-admin-ui/src/views/Configuration/Configuration.js +++ b/packages/server-admin-ui/src/views/Configuration/Configuration.js @@ -26,7 +26,8 @@ export default class PluginConfigurationList extends Component { search: localStorage.getItem(searchStorageKey) || '', statusFilter: localStorage.getItem(statusFilterStorageKey) || 'all', searchResults: null, - selectedPlugin: null + selectedPlugin: null, + wasmEnabled: true // Assume enabled until settings are loaded } this.lastOpenedPlugin = '--' this.handleSearch = this.handleSearch.bind(this) @@ -155,17 +156,37 @@ export default class PluginConfigurationList extends Component { } componentDidMount() { - fetch(`${window.serverRoutesPrefix}/plugins`, { - credentials: 'same-origin' - }) - .then((response) => { + // Fetch both plugins and settings in parallel + Promise.all([ + fetch(`${window.serverRoutesPrefix}/plugins`, { + credentials: 'same-origin' + }).then((response) => { if (response.status === 200) { return response.json() } else { throw new Error('/plugins request failed:' + response.status) } + }), + fetch(`${window.serverRoutesPrefix}/settings`, { + credentials: 'same-origin' }) - .then((plugins) => { + .then((response) => { + if (response.status === 200) { + return response.json() + } else { + // Settings fetch failed, assume WASM is enabled + return { interfaces: { wasm: true } } + } + }) + .catch(() => { + // Settings fetch failed, assume WASM is enabled + return { interfaces: { wasm: true } } + }) + ]) + .then(([plugins, settings]) => { + // Check if WASM interface is enabled (default true if not specified) + const wasmEnabled = settings?.interfaces?.wasm !== false + // Set initial selected plugin from URL or localStorage const currentPluginId = this.props.match.params.pluginid const lastOpenPluginId = localStorage.getItem(openPluginStorageKey) @@ -181,7 +202,7 @@ export default class PluginConfigurationList extends Component { ) } - this.setState({ plugins, selectedPlugin }) + this.setState({ plugins, selectedPlugin, wasmEnabled }) // Scroll to the initially selected plugin if one exists (from URL/bookmark) if (selectedPlugin) { @@ -294,6 +315,23 @@ export default class PluginConfigurationList extends Component { (plugin.data.configuration === null || plugin.data.configuration === undefined) + // Check if this is a WASM plugin with WASM interface disabled + const isWasmPlugin = plugin.type === 'wasm' + const wasmDisabledForPlugin = + isWasmPlugin && !this.state.wasmEnabled + + // Determine badge class and text + let badgeClass = 'badge-secondary' + let badgeText = 'Disabled' + + if (wasmDisabledForPlugin) { + badgeClass = 'badge-danger' + badgeText = 'WASM disabled' + } else if (plugin.data.enabled && !configurationRequired) { + badgeClass = 'badge-success' + badgeText = 'Enabled' + } + return (
-
- {plugin.data.enabled && !configurationRequired - ? 'Enabled' - : 'Disabled'} +
+ {badgeText}
this.selectPlugin(null)} saveData={(data) => { - if (this.state.selectedPlugin.data.configuration === undefined) { + // Only auto-enable on first-ever configuration save + // Check if plugin was never configured before (no enabled state set) + // This allows plugins that are already enabled/disabled to be toggled + if ( + this.state.selectedPlugin.data.enabled === undefined && + data.enabled === undefined + ) { data.enabled = true } return this.saveData(this.state.selectedPlugin.id, data) diff --git a/packages/server-admin-ui/src/views/ServerConfig/Settings.js b/packages/server-admin-ui/src/views/ServerConfig/Settings.js index 4b3c4cddb..ad750b1d3 100644 --- a/packages/server-admin-ui/src/views/ServerConfig/Settings.js +++ b/packages/server-admin-ui/src/views/ServerConfig/Settings.js @@ -371,7 +371,8 @@ const SettableInterfaces = { applicationData: 'Application Data Storage', logfiles: 'Data log files access', 'nmea-tcp': 'NMEA 0183 over TCP (10110)', - tcp: 'Signal K over TCP (8375)' + tcp: 'Signal K over TCP (8375)', + wasm: 'WebAssembly Runtime' } const ReduxedSettings = connect()(ServerSettings) diff --git a/packages/server-api/package.json b/packages/server-api/package.json index aa4e2cbfa..851851f1a 100644 --- a/packages/server-api/package.json +++ b/packages/server-api/package.json @@ -1,6 +1,6 @@ { "name": "@signalk/server-api", - "version": "2.10.1", + "version": "2.10.2", "description": "signalk-server Typescript API for plugins etc with relevant implementation classes", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/server-api/wit/signalk.wit b/packages/server-api/wit/signalk.wit new file mode 100644 index 000000000..44aeb411a --- /dev/null +++ b/packages/server-api/wit/signalk.wit @@ -0,0 +1,98 @@ +// Signal K Server Plugin API - WASM Interface Types (WIT) +// This defines the contract between WASM plugins and the Signal K server + +package signalk:plugin-api@1.0.0; + +// World definition - entry point for WASM plugins +world plugin { + // Plugin exports these functions + export plugin-interface; + + // Plugin can use these server APIs + import delta-handler; + import plugin-config; + import plugin-status; + import full-model; +} + +// Plugin lifecycle and metadata interface +interface plugin-interface { + // Plugin metadata + id: func() -> string; + name: func() -> string; + schema: func() -> string; // JSON schema as string + + // Lifecycle hooks + start: func(config: string) -> result<_, string>; + stop: func() -> result<_, string>; +} + +// Delta message handling +interface delta-handler { + // Delta data types + record source-ref { + label: string, + type: option, + } + + record path-value { + path: string, + value: string, // JSON-encoded value + } + + record update { + source: source-ref, + timestamp: string, // ISO 8601 timestamp + values: list, + } + + record delta { + context: string, + updates: list, + } + + // Plugin → Server: emit delta message + handle-message: func(plugin-id: string, delta: delta); + + // Server → Plugin: register callback for incoming deltas + // Note: Callback registration will be done during plugin initialization + on-delta: func(delta: delta); +} + +// Plugin configuration and storage +interface plugin-config { + // Read saved plugin configuration (JSON string) + read-plugin-options: func() -> string; + + // Save plugin configuration (persisted to disk) + save-plugin-options: func(config: string) -> result<_, string>; + + // Get plugin data directory path (VFS root) + get-data-dir-path: func() -> string; +} + +// Plugin status and logging +interface plugin-status { + // Set plugin status message (shown in Admin UI) + set-plugin-status: func(message: string); + + // Set plugin error message (shown in Admin UI) + set-plugin-error: func(message: string); + + // Debug logging (respects plugin debug settings) + debug: func(message: string); + + // Error logging + error: func(message: string); +} + +// Signal K full model access +interface full-model { + // Read data from vessel.self path + // Returns JSON-encoded value or none if path doesn't exist + get-self-path: func(path: string) -> option; + + // Read data from any context path + // Returns JSON-encoded value or none if path doesn't exist + get-path: func(path: string) -> option; +} diff --git a/src/config/config.ts b/src/config/config.ts index aa79c7528..11359a246 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -76,6 +76,7 @@ export interface Config { keepMostRecentLogsOnly?: boolean logCountToKeep?: number enablePluginLogging?: boolean + enableWasmLogging?: boolean loggingDirectory?: string sourcePriorities?: any trustProxy?: boolean | string | number diff --git a/src/index.ts b/src/index.ts index b07e67ed5..113a411ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -544,58 +544,58 @@ class Server { return this } - stop(cb?: () => void) { - return new Promise((resolve, reject) => { - if (!this.app.started) { - resolve(this) - } else { - try { - _.each(this.app.interfaces, (intf: any) => { - if ( - intf !== null && - typeof intf === 'object' && - typeof intf.stop === 'function' - ) { - intf.stop() - } - }) + async stop(cb?: () => void) { + if (!this.app.started) { + return this + } - this.app.intervals.forEach((interval) => { - clearInterval(interval) - }) + try { + _.each(this.app.interfaces, (intf: any) => { + if ( + intf !== null && + typeof intf === 'object' && + typeof intf.stop === 'function' + ) { + intf.stop() + } + }) - this.app.providers.forEach((providerHolder) => { - providerHolder.pipeElements[0].end() - }) + this.app.intervals.forEach((interval) => { + clearInterval(interval) + }) - debug('Closing server...') - - const that = this - this.app.server.close(() => { - debug('Server closed') - if (that.app.redirectServer) { - try { - that.app.redirectServer.close(() => { - debug('Redirect server closed') - delete that.app.redirectServer - that.app.started = false - cb && cb() - resolve(that) - }) - } catch (err) { - reject(err) - } - } else { - that.app.started = false - cb && cb() - resolve(that) + this.app.providers.forEach((providerHolder) => { + providerHolder.pipeElements[0].end() + }) + + debug('Closing server...') + + const that = this + return new Promise((resolve, reject) => { + this.app.server.close(() => { + debug('Server closed') + if (that.app.redirectServer) { + try { + that.app.redirectServer.close(() => { + debug('Redirect server closed') + delete that.app.redirectServer + that.app.started = false + cb && cb() + resolve(that) + }) + } catch (err) { + reject(err) } - }) - } catch (err) { - reject(err) - } - } - }) + } else { + that.app.started = false + cb && cb() + resolve(that) + } + }) + }) + } catch (err) { + throw err + } } } @@ -694,7 +694,7 @@ async function startInterfaces( !_interface.forceInactive ) { debug(`Starting interface '${name}'`) - _interface.data = _interface.start() + _interface.data = await _interface.start() } else { debug(`Not starting interface '${name}' by forceInactive`) } diff --git a/src/interfaces/plugins.ts b/src/interfaces/plugins.ts index aa2817840..877877518 100644 --- a/src/interfaces/plugins.ts +++ b/src/interfaces/plugins.ts @@ -63,6 +63,7 @@ const queryRequest = require('../requestResponse').queryRequest import { getMetadata } from '@signalk/signalk-schema' import { HistoryApi } from '@signalk/server-api/history' import { HistoryApiHttpRegistry } from '../api/history' +import { derivePluginId } from '../pluginid' // #521 Returns path to load plugin-config assets. const getPluginConfigPublic = getModulePublic('@signalk/plugin-config') @@ -86,6 +87,10 @@ interface PluginInfo extends Plugin { packageLocation: string version: string state: string + type?: string // 'wasm' for WASM plugins, undefined for Node.js plugins + isWebapp?: boolean + isEmbeddableWebapp?: boolean + webappMounted?: boolean } function backwardsCompat(url: string) { @@ -218,7 +223,8 @@ module.exports = (theApp: any) => { statusMessage, uiSchema, state: plugin.state, - data + data, + type: plugin.type // Include type to identify WASM plugins in Admin UI }) }) .catch((err) => { @@ -298,7 +304,26 @@ module.exports = (theApp: any) => { async function startPlugins(app: any) { app.plugins = [] app.pluginsMap = {} - const modules = modulesWithKeyword(app.config, 'signalk-node-server-plugin') + // Expose getPluginOptions for use by other modules (e.g., webapps.js) + app.getPluginOptions = getPluginOptions + + // Discover both Node.js and WASM plugins + const jsModules = modulesWithKeyword( + app.config, + 'signalk-node-server-plugin' + ) + const wasmModules = modulesWithKeyword(app.config, 'signalk-wasm-plugin') + + // Combine and deduplicate by module name (a plugin might have both keywords) + const seenModules = new Set() + const modules = [...jsModules, ...wasmModules].filter((moduleData: any) => { + if (seenModules.has(moduleData.module)) { + return false + } + seenModules.add(moduleData.module) + return true + }) + await Promise.all( modules.map((moduleData: any) => { return registerPlugin( @@ -401,6 +426,61 @@ module.exports = (theApp: any) => { ) { debug('Registering plugin ' + pluginName) try { + // Check if this is a WASM plugin (wasmManifest is now part of NpmPackageData) + if (metadata.wasmManifest) { + // This is a WASM plugin - check if WASM interface is enabled + const wasmEnabled = app.config.settings.interfaces?.wasm !== false + if (!wasmEnabled) { + debug( + `WASM plugin ${pluginName} discovered but WASM interface disabled - registering minimal entry` + ) + // Create minimal plugin entry so it appears in Plugin Config with "No WASM" badge + // Derive plugin ID from npm package name (@ → _, / → _) + const pluginId = derivePluginId(pluginName) + // Use signalk.displayName (standard SignalK convention) or fall back to package name + const pluginDisplayName = metadata.signalk?.displayName || pluginName + + const minimalPlugin: any = { + id: pluginId, + name: pluginDisplayName, + type: 'wasm', + packageName: pluginName, + version: metadata.version, + description: metadata.description || '', + keywords: metadata.keywords || [], + packageLocation: location, + enabled: false, + state: 'disabled', + statusMessage: () => 'WASM interface disabled', + schema: () => ({}), + uiSchema: () => ({}), + start: () => {}, + stop: () => Promise.resolve(), + enableLogging: false, + enableDebug: false + } + + app.plugins.push(minimalPlugin) + app.pluginsMap[pluginId] = minimalPlugin + debug( + `Registered minimal WASM plugin entry: ${pluginId} (WASM disabled)` + ) + return + } + // Route to WASM loader + debug(`Detected WASM plugin: ${pluginName}`) + const { registerWasmPlugin } = require('../wasm') + await registerWasmPlugin( + app, + pluginName, + metadata, + location, + theApp.config.configPath + ) + return + } + + // Standard Node.js plugin await doRegisterPlugin(app, pluginName, metadata, location) } catch (e) { console.error(e) @@ -677,6 +757,15 @@ module.exports = (theApp: any) => { startupOptions.enabled = true startupOptions.configuration = {} plugin.enabledByDefault = true + // Persist the default-enabled state to disk so the plugin can be disabled later + savePluginOptions(plugin.id, startupOptions, (err) => { + if (err) { + console.error( + `Error saving default-enabled options for ${plugin.id}:`, + err + ) + } + }) } plugin.enableDebug = startupOptions.enableDebug diff --git a/src/interfaces/wasm.ts b/src/interfaces/wasm.ts new file mode 100644 index 000000000..641f34735 --- /dev/null +++ b/src/interfaces/wasm.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Interface + * + * Manages the WASM runtime as a Signal K interface. + * Can be enabled/disabled via settings.interfaces.wasm + */ + +import Debug from 'debug' +import { initializeWasm, shutdownAllWasmPlugins } from '../wasm' + +const debug = Debug('signalk:interfaces:wasm') + +module.exports = (app: any) => { + const api: any = {} + + api.mdns = { + name: '_signalk-wasm', + type: 'tcp', + port: app.config.port + } + + api.start = () => { + debug('Starting WASM interface') + try { + const { wasmRuntime, wasmSubscriptionManager } = initializeWasm() + app.wasmRuntime = wasmRuntime + app.wasmSubscriptionManager = wasmSubscriptionManager + debug('WASM runtime initialized successfully') + return { enabled: true } + } catch (error) { + debug('WASM runtime initialization failed:', error) + return { enabled: false, error } + } + } + + api.stop = () => { + debug('Stopping WASM interface') + try { + shutdownAllWasmPlugins() + } catch (error) { + debug('WASM shutdown error:', error) + } + } + + return api +} diff --git a/src/interfaces/webapps.js b/src/interfaces/webapps.js index 58906a88e..5a141c8e7 100644 --- a/src/interfaces/webapps.js +++ b/src/interfaces/webapps.js @@ -26,16 +26,25 @@ import { uniqBy } from 'lodash' module.exports = function (app) { return { start: function () { - app.webapps = mountWebModules(app, 'signalk-webapp').map( + // Preserve any existing webapps (e.g., from WASM plugins loaded earlier) + const existingWebapps = app.webapps || [] + const nodeWebapps = mountWebModules(app, 'signalk-webapp').map( (moduleData) => moduleData.metadata ) + // Merge Node.js webapps with existing WASM webapps, avoiding duplicates + app.webapps = uniqBy([...nodeWebapps, ...existingWebapps], 'name') app.addons = mountWebModules(app, 'signalk-node-server-addon').map( (moduleData) => moduleData.metadata ) - app.embeddablewebapps = mountWebModules( + const existingEmbeddableWebapps = app.embeddablewebapps || [] + const nodeEmbeddableWebapps = mountWebModules( app, 'signalk-embeddable-webapp' ).map((moduleData) => moduleData.metadata) + app.embeddablewebapps = uniqBy( + [...nodeEmbeddableWebapps, ...existingEmbeddableWebapps], + 'name' + ) app.pluginconfigurators = mountWebModules( app, 'signalk-plugin-configurator' diff --git a/src/modules.ts b/src/modules.ts index 608888f8d..98c803264 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -36,11 +36,32 @@ interface NpmDistTags { [prerelease: string]: string } +export interface WasmCapabilities { + network?: boolean + storage?: 'vfs-only' | 'none' + dataRead?: boolean + dataWrite?: boolean + serialPorts?: boolean + putHandlers?: boolean + httpEndpoints?: boolean + resourceProvider?: boolean + weatherProvider?: boolean + radarProvider?: boolean + rawSockets?: boolean +} + export interface NpmPackageData { name: string version: string date: string keywords: string[] + description?: string + // WASM plugin fields + wasmManifest?: string // Path to WASM binary (e.g., "build/plugin.wasm") + wasmCapabilities?: WasmCapabilities + signalk?: { + displayName?: string + } } interface NpmSearchResponse { diff --git a/src/pluginid.ts b/src/pluginid.ts new file mode 100644 index 000000000..88244c97c --- /dev/null +++ b/src/pluginid.ts @@ -0,0 +1,20 @@ +/** + * Derives a plugin ID from an npm package name. + * + * The npm package name is used as the canonical plugin identifier, + * with minimal transformation for filesystem safety: + * - @ is replaced with _ + * - / is replaced with _ + * + * Examples: + * - "@signalk/example-weather-plugin" → "_signalk_example-weather-plugin" + * - "my-simple-plugin" → "my-simple-plugin" (unchanged) + * + * This ensures: + * - Unique plugin IDs (npm guarantees package name uniqueness) + * - No discrepancies between package name and plugin ID + * - Filesystem-safe identifiers for config files + */ +export function derivePluginId(packageName: string): string { + return packageName.replace(/@/g, '_').replace(/\//g, '_') +} diff --git a/src/serverroutes.ts b/src/serverroutes.ts index 5ae98d15a..e9345b454 100644 --- a/src/serverroutes.ts +++ b/src/serverroutes.ts @@ -134,6 +134,8 @@ interface App logging: { rememberDebug: (r: boolean) => void enableDebug: (r: string) => boolean + addDebug: (name: string) => void + removeDebug: (name: string) => void } activateSourcePriorities: () => void streambundle: StreamBundle @@ -143,6 +145,21 @@ interface ModuleInfo { name: string } +// Helper function to update WASM debug logging +function updateWasmDebugLogging(enabled: boolean, app: App) { + const wasmDebugPattern = 'signalk:wasm:*' + + if (enabled) { + // Add WASM debug using the logging module's API + debug('Enabling WASM debug logging') + app.logging.addDebug(wasmDebugPattern) + } else { + // Remove WASM debug using the logging module's API + debug('Disabling WASM debug logging') + app.logging.removeDebug(wasmDebugPattern) + } +} + module.exports = function ( app: App, saveSecurityConfig: SecurityConfigSaver, @@ -175,6 +192,12 @@ module.exports = function ( let securityWasEnabled = false const restoreSessions = new Map() + // Initialize WASM logging on startup based on config + if (app.config.settings.enableWasmLogging !== false) { + // Default to enabled if not explicitly disabled + updateWasmDebugLogging(true, app) + } + const logopath = path.resolve(app.config.configPath, 'logo.svg') if (fs.existsSync(logopath)) { debug(`Found custom logo at ${logopath}, adding route for it`) @@ -622,7 +645,10 @@ module.exports = function ( enablePluginLogging: isUndefined(app.config.settings.enablePluginLogging) || app.config.settings.enablePluginLogging, - trustProxy: app.config.settings.trustProxy || false + trustProxy: app.config.settings.trustProxy || false, + enableWasmLogging: + isUndefined(app.config.settings.enableWasmLogging) || + app.config.settings.enableWasmLogging }, loggingDirectory: app.config.settings.loggingDirectory, pruneContextsMinutes: app.config.settings.pruneContextsMinutes || 60, @@ -754,6 +780,12 @@ module.exports = function ( app.config.settings.trustProxy = settings.options.trustProxy } + if (!isUndefined(settings.options.enableWasmLogging)) { + app.config.settings.enableWasmLogging = settings.options.enableWasmLogging + // Update WASM debug logging dynamically + updateWasmDebugLogging(settings.options.enableWasmLogging ?? true, app) + } + if (!isUndefined(settings.port)) { app.config.settings.port = Number(settings.port) } diff --git a/src/wasm/bindings/binary-stream.ts b/src/wasm/bindings/binary-stream.ts new file mode 100644 index 000000000..7537e8750 --- /dev/null +++ b/src/wasm/bindings/binary-stream.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Binary Stream FFI Bindings + * + * Provides FFI bindings for WASM plugins to emit binary data streams + * to connected WebSocket clients. + */ + +import Debug from 'debug' + +const debug = Debug('signalk:wasm:binary-stream') + +/** + * Helper to read binary data from WASM memory + */ +export function createBinaryDataReader(memoryRef: { + current: WebAssembly.Memory | null +}) { + return (ptr: number, len: number): Buffer => { + if (!memoryRef.current) { + throw new Error('WASM memory not initialized') + } + const bytes = new Uint8Array(memoryRef.current.buffer, ptr, len) + return Buffer.from(bytes) + } +} + +/** + * Create the sk_emit_binary_stream host binding + * + * WASM plugins call this to push binary data to stream subscribers. + * Stream IDs should be scoped: "plugins/{pluginId}/{streamName}" or "radars/{radarId}" + * + * @param pluginId - Plugin identifier + * @param app - SignalK application instance + * @param readUtf8String - Function to read UTF-8 strings from WASM memory + * @param readBinaryData - Function to read binary data from WASM memory + * @returns FFI binding function + */ +export function createBinaryStreamBinding( + pluginId: string, + app: any, + readUtf8String: (ptr: number, len: number) => string, + readBinaryData: (ptr: number, len: number) => Buffer +): ( + streamIdPtr: number, + streamIdLen: number, + dataPtr: number, + dataLen: number +) => number { + return ( + streamIdPtr: number, + streamIdLen: number, + dataPtr: number, + dataLen: number + ): number => { + try { + // Extract stream ID and data from WASM memory + const streamId = readUtf8String(streamIdPtr, streamIdLen) + const data = readBinaryData(dataPtr, dataLen) + + debug( + `[${pluginId}] sk_emit_binary_stream: streamId="${streamId}", ` + + `dataLen=${dataLen} bytes` + ) + + // Validate stream ID format + // Allow: + // - "radars/{radarId}" (for radar providers) + // - "plugins/{pluginId}/{streamName}" (for custom streams) + const validRadarStream = /^radars\/[a-zA-Z0-9_-]+$/.test(streamId) + const validPluginStream = streamId.startsWith(`plugins/${pluginId}/`) + + if (!validRadarStream && !validPluginStream) { + debug( + `[${pluginId}] Invalid stream ID: "${streamId}". ` + + `Expected "radars/{radarId}" or "plugins/${pluginId}/{streamName}"` + ) + return 0 // Failure + } + + // Push to stream manager + if (app && app.binaryStreamManager) { + app.binaryStreamManager.emitData(streamId, data) + return 1 // Success + } else { + debug(`[${pluginId}] Binary stream manager not available`) + return 0 // Failure + } + } catch (error) { + debug(`[${pluginId}] sk_emit_binary_stream error: ${error}`) + return 0 // Failure + } + } +} diff --git a/src/wasm/bindings/env-imports.ts b/src/wasm/bindings/env-imports.ts new file mode 100644 index 000000000..911787db3 --- /dev/null +++ b/src/wasm/bindings/env-imports.ts @@ -0,0 +1,1043 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * WASM Environment Imports (Host Bindings) + * + * Provides the Signal K API functions that WASM plugins can import + */ + +import Debug from 'debug' +import { SKVersion } from '@signalk/server-api' +import { WasmCapabilities } from '../types' +import { createResourceProviderBinding } from './resource-provider' +import { createWeatherProviderBinding } from './weather-provider' +import { + createRadarProviderBinding, + createRadarEmitSpokesBinding +} from './radar-provider' +import { + createBinaryStreamBinding, + createBinaryDataReader +} from './binary-stream' +import { socketManager, tcpSocketManager } from './socket-manager' +import * as fs from 'fs' +import * as path from 'path' + +const debug = Debug('signalk:wasm:bindings') + +/** + * Options for creating environment imports + */ +export interface EnvImportsOptions { + pluginId: string + capabilities: WasmCapabilities + app?: any + memoryRef: { current: WebAssembly.Memory | null } + rawExports: { current: any } + asLoaderInstance: { current: any } + configPath?: string + packageName?: string +} + +/** + * Helper to read UTF-8 strings from WASM memory + */ +export function createUtf8Reader(memoryRef: { + current: WebAssembly.Memory | null +}) { + return (ptr: number, len: number): string => { + if (!memoryRef.current) { + throw new Error('AssemblyScript module memory not initialized') + } + const bytes = new Uint8Array(memoryRef.current.buffer, ptr, len) + const decoder = new TextDecoder('utf-8') + return decoder.decode(bytes) + } +} + +/** + * Create environment imports for a WASM plugin + */ +export function createEnvImports( + options: EnvImportsOptions +): Record { + const { + pluginId, + capabilities, + app, + memoryRef, + rawExports, + asLoaderInstance, + configPath, + packageName: _packageName + } = options + + const readUtf8String = createUtf8Reader(memoryRef) + const readBinaryData = createBinaryDataReader(memoryRef) + + const envImports: Record = { + // AssemblyScript runtime requirements + abort: (msg: number, file: number, line: number, column: number) => { + debug(`WASM abort called: ${msg} at ${file}:${line}:${column}`) + }, + seed: () => { + return Date.now() * Math.random() + }, + 'console.log': (ptr: number, len: number) => { + try { + const message = readUtf8String(ptr, len) + debug(`[${pluginId}] ${message}`) + } catch (error) { + debug(`WASM console.log error: ${error}`) + } + }, + + // Signal K API functions + sk_debug: (ptr: number, len: number) => { + try { + const message = readUtf8String(ptr, len) + debug(`[${pluginId}] ${message}`) + } catch (error) { + debug(`Plugin debug error: ${error}`) + } + }, + + sk_set_status: (ptr: number, len: number) => { + try { + const message = readUtf8String(ptr, len) + debug(`[${pluginId}] Status: ${message}`) + if (app && app.setPluginStatus) { + app.setPluginStatus(pluginId, message) + } + } catch (error) { + debug(`Plugin set status error: ${error}`) + } + }, + + sk_set_error: (ptr: number, len: number) => { + try { + const message = readUtf8String(ptr, len) + debug(`[${pluginId}] Error: ${message}`) + if (app && app.setPluginError) { + app.setPluginError(pluginId, message) + } + } catch (error) { + debug(`Plugin set error error: ${error}`) + } + }, + + // Get value from vessels.self path + sk_get_self_path: ( + pathPtr: number, + pathLen: number, + bufPtr: number, + bufMaxLen: number + ): number => { + try { + const path = readUtf8String(pathPtr, pathLen) + debug(`[${pluginId}] getSelfPath: ${path}`) + + if (!app || !app.getSelfPath) { + debug(`[${pluginId}] app.getSelfPath not available`) + return 0 + } + + const value = app.getSelfPath(path) + if (value === undefined || value === null) { + return 0 + } + + // Serialize value to JSON + const jsonStr = JSON.stringify(value) + const jsonBytes = Buffer.from(jsonStr, 'utf8') + + if (jsonBytes.length > bufMaxLen) { + debug( + `[${pluginId}] getSelfPath buffer too small: need ${jsonBytes.length}, have ${bufMaxLen}` + ) + return 0 + } + + // Write to WASM memory + if (memoryRef.current) { + const memView = new Uint8Array(memoryRef.current.buffer) + memView.set(jsonBytes, bufPtr) + return jsonBytes.length + } + + return 0 + } catch (error) { + debug(`[${pluginId}] getSelfPath error: ${error}`) + return 0 + } + }, + + /** + * Emit a delta message to the Signal K server + * + * @param ptr - Pointer to delta JSON string in WASM memory + * @param len - Length of delta JSON string + * @param version - Signal K version: 1 = v1 (default), 2 = v2 + * + * Plugins should use v1 for regular navigation data (the default). + * Use v2 for Course API paths and other v2-specific data to prevent + * v2 data from being mixed into the v1 full data model. + * + * This mirrors the TypeScript plugin API where handleMessage accepts + * an optional skVersion parameter. + */ + sk_handle_message: (ptr: number, len: number, version: number = 1) => { + try { + const deltaJson = readUtf8String(ptr, len) + debug( + `[${pluginId}] Emitting delta (v${version === 2 ? '2' : '1'}): ${deltaJson.substring(0, 200)}...` + ) + if (app && app.handleMessage) { + try { + const delta = JSON.parse(deltaJson) + const skVersion = version === 2 ? SKVersion.v2 : SKVersion.v1 + app.handleMessage(pluginId, delta, skVersion) + debug(`[${pluginId}] Delta processed by server (${skVersion})`) + } catch (parseError) { + debug(`[${pluginId}] Failed to parse/process delta: ${parseError}`) + } + } else { + debug( + `[${pluginId}] Warning: app.handleMessage not available, delta not processed` + ) + } + } catch (error) { + debug(`Plugin handle message error: ${error}`) + } + }, + + /** + * Publish a SignalK notification (v6) + * + * @param pathPtr - Pointer to notification path string (e.g., "notifications.navigation.closestApproach.radar:1:target:5") + * @param pathLen - Length of path string + * @param valuePtr - Pointer to notification value JSON + * @param valueLen - Length of value JSON + * @returns 0 on success, -1 on error + */ + sk_publish_notification: ( + pathPtr: number, + pathLen: number, + valuePtr: number, + valueLen: number + ): number => { + try { + const path = readUtf8String(pathPtr, pathLen) + const valueJson = readUtf8String(valuePtr, valueLen) + + debug(`[${pluginId}] Publishing notification: ${path}`) + + if (!app || !app.handleMessage) { + debug(`[${pluginId}] app.handleMessage not available`) + return -1 + } + + // Parse and validate the notification value + let notificationValue: any + try { + notificationValue = JSON.parse(valueJson) + } catch (e) { + debug(`[${pluginId}] Invalid notification JSON: ${e}`) + return -1 + } + + // Validate required notification fields per SignalK spec + // Notifications must have: state, method, message + if (!notificationValue.state) { + debug(`[${pluginId}] Notification missing required 'state' field`) + return -1 + } + + const validStates = ['normal', 'alert', 'warn', 'alarm', 'emergency'] + if (!validStates.includes(notificationValue.state)) { + debug( + `[${pluginId}] Invalid notification state: ${notificationValue.state}` + ) + return -1 + } + + // Build the delta message for the notification + const delta = { + updates: [ + { + values: [ + { + path: path, + value: notificationValue + } + ] + } + ] + } + + // Notifications should be processed normally (version 1) + app.handleMessage(pluginId, delta) + debug( + `[${pluginId}] Notification published: ${path} state=${notificationValue.state}` + ) + + return 0 // Success + } catch (error) { + debug(`[${pluginId}] sk_publish_notification error: ${error}`) + return -1 + } + }, + + // ========================================================================== + // Plugin Configuration API + // ========================================================================== + + /** + * Read plugin configuration from plugin-config-data + * Uses: ~/.signalk/plugin-config-data/{pluginId}.json + * + * This matches the storage location used by JS plugins. + * + * @param bufPtr - Buffer to write config JSON into + * @param bufMaxLen - Maximum buffer size + * @returns Number of bytes written, or 0 if no config / error + */ + sk_read_config: (bufPtr: number, bufMaxLen: number): number => { + try { + const cfgPath = configPath || app?.config?.configPath + if (!cfgPath) { + debug(`[${pluginId}] sk_read_config: configPath not available`) + return 0 + } + + // Plugin config path: plugin-config-data/{pluginId}.json (same as JS plugins) + const configFile = path.join( + cfgPath, + 'plugin-config-data', + `${pluginId}.json` + ) + + let configJson = '{}' + if (fs.existsSync(configFile)) { + try { + const rawConfig = fs.readFileSync(configFile, 'utf8') + const parsed = JSON.parse(rawConfig) + // Return just the configuration object (not enabled/enableLogging flags) + configJson = JSON.stringify(parsed.configuration || {}) + } catch (e) { + debug(`[${pluginId}] Could not read config: ${e}`) + } + } + + debug( + `[${pluginId}] Reading config from ${configFile}: ${configJson.substring(0, 100)}...` + ) + + const encoder = new TextEncoder() + const configBytes = encoder.encode(configJson) + + if (configBytes.length > bufMaxLen) { + debug( + `[${pluginId}] Config buffer too small: need ${configBytes.length}, have ${bufMaxLen}` + ) + return 0 + } + + if (!memoryRef.current) return 0 + const memView = new Uint8Array(memoryRef.current.buffer) + memView.set(configBytes, bufPtr) + + return configBytes.length + } catch (error) { + debug(`[${pluginId}] sk_read_config error: ${error}`) + return 0 + } + }, + + /** + * Save plugin configuration to plugin-config-data + * Uses: ~/.signalk/plugin-config-data/{pluginId}.json + * + * This matches the storage location used by JS plugins. + * + * @param configPtr - Pointer to config JSON string + * @param configLen - Length of config JSON + * @returns 0 on success, negative on error + */ + sk_save_config: (configPtr: number, configLen: number): number => { + try { + const cfgPath = configPath || app?.config?.configPath + if (!cfgPath) { + debug(`[${pluginId}] sk_save_config: configPath not available`) + return -1 + } + + const configJson = readUtf8String(configPtr, configLen) + debug(`[${pluginId}] Saving config: ${configJson.substring(0, 100)}...`) + + // Validate JSON + const configuration = JSON.parse(configJson) + + // Plugin config path: plugin-config-data/{pluginId}.json (same as JS plugins) + const configDataDir = path.join(cfgPath, 'plugin-config-data') + const configFile = path.join(configDataDir, `${pluginId}.json`) + + // Create directory if needed + if (!fs.existsSync(configDataDir)) { + fs.mkdirSync(configDataDir, { recursive: true }) + } + + // Read existing config to preserve enabled/enableLogging flags + let existingConfig: any = { enabled: true } + if (fs.existsSync(configFile)) { + try { + existingConfig = JSON.parse(fs.readFileSync(configFile, 'utf8')) + } catch (e) { + debug(`[${pluginId}] Could not read existing config: ${e}`) + } + } + + // Update configuration while preserving other fields + existingConfig.configuration = configuration + + fs.writeFileSync( + configFile, + JSON.stringify(existingConfig, null, 2), + 'utf8' + ) + debug(`[${pluginId}] Config saved to ${configFile}`) + + return 0 + } catch (error) { + debug(`[${pluginId}] sk_save_config error: ${error}`) + return -1 + } + }, + + // Capability checking + sk_has_capability: (capPtr: number, capLen: number): number => { + try { + const capability = readUtf8String(capPtr, capLen) + debug(`[${pluginId}] Checking capability: ${capability}`) + if (capability === 'network') { + return capabilities.network ? 1 : 0 + } + if (capability === 'rawSockets') { + return capabilities.rawSockets ? 1 : 0 + } + return 0 + } catch (error) { + debug(`Plugin capability check error: ${error}`) + return 0 + } + }, + + // PUT Handler Registration + sk_register_put_handler: ( + contextPtr: number, + contextLen: number, + pathPtr: number, + pathLen: number + ): number => { + try { + const context = readUtf8String(contextPtr, contextLen) + const path = readUtf8String(pathPtr, pathLen) + debug( + `[${pluginId}] Registering PUT handler: context=${context}, path=${path}` + ) + + if (!capabilities.putHandlers) { + debug(`[${pluginId}] PUT handlers capability not granted`) + return 0 + } + + debug( + `[${pluginId}] app available: ${!!app}, app.registerActionHandler available: ${!!(app && app.registerActionHandler)}` + ) + + if (app && app.registerActionHandler) { + // Send meta message to indicate this path supports PUT + if (app.handleMessage) { + app.handleMessage(pluginId, { + updates: [ + { + meta: [ + { + path: path, + value: { supportsPut: true } + } + ] + } + ] + }) + debug(`[${pluginId}] Sent supportsPut meta for ${path}`) + } + + const callback = ( + cbContext: string, + cbPath: string, + value: any, + cb: (result: any) => void + ) => { + debug( + `[${pluginId}] PUT request received: ${cbContext}.${cbPath} = ${JSON.stringify(value)}` + ) + + const handlerName = `handle_put_${cbContext.replace(/\./g, '_')}_${cbPath.replace(/\./g, '_')}` + const exports = + asLoaderInstance.current?.exports || rawExports.current + const handlerFunc = exports?.[handlerName] + + if (handlerFunc) { + debug(`[${pluginId}] Calling WASM handler: ${handlerName}`) + const valueJson = JSON.stringify(value) + + try { + let responseJson: string + + if (asLoaderInstance.current) { + responseJson = handlerFunc(valueJson) + } else if (rawExports.current?.allocate) { + // Rust library plugin: buffer-based string passing + const valueBytes = Buffer.from(valueJson, 'utf8') + const valuePtr = rawExports.current.allocate( + valueBytes.length + ) + const responseMaxLen = 8192 + const responsePtr = + rawExports.current.allocate(responseMaxLen) + + const memory = rawExports.current.memory as WebAssembly.Memory + const memView = new Uint8Array(memory.buffer) + memView.set(valueBytes, valuePtr) + + const writtenLen = handlerFunc( + valuePtr, + valueBytes.length, + responsePtr, + responseMaxLen + ) + + const responseBytes = new Uint8Array( + memory.buffer, + responsePtr, + writtenLen + ) + responseJson = new TextDecoder('utf-8').decode(responseBytes) + + if (rawExports.current.deallocate) { + rawExports.current.deallocate(valuePtr, valueBytes.length) + rawExports.current.deallocate(responsePtr, responseMaxLen) + } + } else { + throw new Error('Unknown plugin type for PUT handler') + } + + const response = JSON.parse(responseJson) + debug( + `[${pluginId}] PUT handler response: ${JSON.stringify(response)}` + ) + cb(response) + } catch (error) { + debug(`[${pluginId}] PUT handler error: ${error}`) + cb({ + state: 'COMPLETED', + statusCode: 500, + message: `Handler error: ${error}` + }) + } + } else { + debug( + `[${pluginId}] Warning: Handler function not found: ${handlerName}` + ) + cb({ + state: 'COMPLETED', + statusCode: 501, + message: 'Handler not implemented' + }) + } + } + + app.registerActionHandler(context, path, pluginId, callback) + debug( + `[${pluginId}] PUT handler registered successfully via registerActionHandler` + ) + return 1 + } else { + debug(`[${pluginId}] app.registerActionHandler not available`) + return 0 + } + } catch (error) { + debug(`Plugin register PUT handler error: ${error}`) + return 0 + } + }, + + // Resource Provider Registration + sk_register_resource_provider: createResourceProviderBinding( + pluginId, + capabilities, + app, + readUtf8String + ), + + // Weather Provider Registration + sk_register_weather_provider: createWeatherProviderBinding( + pluginId, + capabilities, + app, + readUtf8String + ), + + // Radar Provider Registration + sk_register_radar_provider: createRadarProviderBinding( + pluginId, + capabilities, + app, + readUtf8String + ), + + // ========================================================================== + // Binary Stream API (for high-frequency data streaming) + // ========================================================================== + + /** + * Emit binary data to a stream + * General-purpose binary streaming for any plugin + * @param streamIdPtr - Pointer to stream ID string + * @param streamIdLen - Length of stream ID + * @param dataPtr - Pointer to binary data + * @param dataLen - Length of binary data + * @returns 1 on success, 0 on failure + */ + sk_emit_binary_stream: createBinaryStreamBinding( + pluginId, + app, + readUtf8String, + readBinaryData + ), + + /** + * Emit radar spoke data + * Convenience wrapper for radar providers + * @param radarIdPtr - Pointer to radar ID string + * @param radarIdLen - Length of radar ID + * @param spokeDataPtr - Pointer to binary spoke data (protobuf) + * @param spokeDataLen - Length of spoke data + * @returns 1 on success, 0 on failure + */ + sk_radar_emit_spokes: createRadarEmitSpokesBinding( + pluginId, + capabilities, + app, + readUtf8String, + readBinaryData + ), + + // ========================================================================== + // Raw Socket API (for radar, NMEA, etc.) + // Requires rawSockets capability + // ========================================================================== + + /** + * Create a UDP socket + * @param type - 0 for udp4, 1 for udp6 + * @returns Socket ID (>0), or -1 on error + */ + sk_udp_create: (type: number): number => { + if (!capabilities.rawSockets) { + debug(`[${pluginId}] rawSockets capability not granted`) + return -1 + } + const socketType = type === 1 ? 'udp6' : 'udp4' + return socketManager.createSocket(pluginId, socketType) + }, + + /** + * Bind socket to a port + * @param socketId - Socket ID from sk_udp_create + * @param port - Port number (0 for any available) + * @returns 0 on success, -1 on error + */ + sk_udp_bind: (socketId: number, port: number): number => { + if (!capabilities.rawSockets) return -1 + // Note: bind is async but we return immediately and let it complete + // The socket will be ready by the time we try to receive + socketManager.bind(socketId, port).catch((err) => { + debug(`[${pluginId}] Async bind error: ${err}`) + }) + return 0 + }, + + /** + * Join a multicast group + * @param socketId - Socket ID + * @param addrPtr - Pointer to multicast address string + * @param addrLen - Length of address string + * @param ifacePtr - Pointer to interface address (0 for default) + * @param ifaceLen - Length of interface string + * @returns 0 on success, -1 on error + */ + sk_udp_join_multicast: ( + socketId: number, + addrPtr: number, + addrLen: number, + ifacePtr: number, + ifaceLen: number + ): number => { + if (!capabilities.rawSockets) return -1 + try { + const multicastAddr = readUtf8String(addrPtr, addrLen) + const interfaceAddr = + ifaceLen > 0 ? readUtf8String(ifacePtr, ifaceLen) : undefined + debug( + `[${pluginId}] Joining multicast ${multicastAddr} on interface ${interfaceAddr || 'default'}` + ) + return socketManager.joinMulticast( + socketId, + multicastAddr, + interfaceAddr + ) + } catch (error) { + debug(`[${pluginId}] Join multicast error: ${error}`) + return -1 + } + }, + + /** + * Leave a multicast group + */ + sk_udp_leave_multicast: ( + socketId: number, + addrPtr: number, + addrLen: number, + ifacePtr: number, + ifaceLen: number + ): number => { + if (!capabilities.rawSockets) return -1 + try { + const multicastAddr = readUtf8String(addrPtr, addrLen) + const interfaceAddr = + ifaceLen > 0 ? readUtf8String(ifacePtr, ifaceLen) : undefined + return socketManager.leaveMulticast( + socketId, + multicastAddr, + interfaceAddr + ) + } catch (error) { + debug(`[${pluginId}] Leave multicast error: ${error}`) + return -1 + } + }, + + /** + * Set multicast TTL + */ + sk_udp_set_multicast_ttl: (socketId: number, ttl: number): number => { + if (!capabilities.rawSockets) return -1 + return socketManager.setMulticastTTL(socketId, ttl) + }, + + /** + * Enable/disable multicast loopback + */ + sk_udp_set_multicast_loopback: ( + socketId: number, + enabled: number + ): number => { + if (!capabilities.rawSockets) return -1 + return socketManager.setMulticastLoopback(socketId, enabled !== 0) + }, + + /** + * Enable/disable broadcast + */ + sk_udp_set_broadcast: (socketId: number, enabled: number): number => { + if (!capabilities.rawSockets) return -1 + return socketManager.setBroadcast(socketId, enabled !== 0) + }, + + /** + * Send data via UDP + * @param socketId - Socket ID + * @param addrPtr - Destination address pointer + * @param addrLen - Destination address length + * @param port - Destination port + * @param dataPtr - Data pointer + * @param dataLen - Data length + * @returns Bytes sent, or -1 on error + */ + sk_udp_send: ( + socketId: number, + addrPtr: number, + addrLen: number, + port: number, + dataPtr: number, + dataLen: number + ): number => { + if (!capabilities.rawSockets) return -1 + try { + const address = readUtf8String(addrPtr, addrLen) + if (!memoryRef.current) return -1 + const data = Buffer.from( + new Uint8Array(memoryRef.current.buffer, dataPtr, dataLen) + ) + + // Send is async, but we return 0 immediately and let it complete + socketManager.send(socketId, data, address, port).catch((err) => { + debug(`[${pluginId}] Async send error: ${err}`) + }) + return dataLen // Optimistically return bytes "sent" + } catch (error) { + debug(`[${pluginId}] Send error: ${error}`) + return -1 + } + }, + + /** + * Receive data from UDP socket (non-blocking) + * @param socketId - Socket ID + * @param bufPtr - Buffer to write data into + * @param bufMaxLen - Maximum buffer size + * @param addrOutPtr - Buffer to write source address (at least 46 bytes for IPv6) + * @param portOutPtr - Pointer to write source port (u16) + * @returns Bytes received, 0 if no data, -1 on error + */ + sk_udp_recv: ( + socketId: number, + bufPtr: number, + bufMaxLen: number, + addrOutPtr: number, + portOutPtr: number + ): number => { + if (!capabilities.rawSockets) return -1 + try { + const datagram = socketManager.receive(socketId) + if (!datagram) { + return 0 // No data available + } + + if (!memoryRef.current) return -1 + const memory = memoryRef.current + const memView = new Uint8Array(memory.buffer) + + // Copy data to buffer + const bytesToCopy = Math.min(datagram.data.length, bufMaxLen) + memView.set(datagram.data.slice(0, bytesToCopy), bufPtr) + + // Write source address (null-terminated string) + const addrBytes = Buffer.from(datagram.address + '\0', 'utf8') + memView.set(addrBytes, addrOutPtr) + + // Write source port (u16, little-endian) + const portView = new DataView(memory.buffer) + portView.setUint16(portOutPtr, datagram.port, true) + + return bytesToCopy + } catch (error) { + debug(`[${pluginId}] Recv error: ${error}`) + return -1 + } + }, + + /** + * Get number of buffered datagrams waiting to be received + */ + sk_udp_pending: (socketId: number): number => { + if (!capabilities.rawSockets) return -1 + return socketManager.getBufferedCount(socketId) + }, + + /** + * Close a socket + */ + sk_udp_close: (socketId: number): void => { + if (!capabilities.rawSockets) return + socketManager.close(socketId) + }, + + // ========================================================================== + // TCP Socket API (for protocols requiring persistent connections) + // Requires rawSockets capability + // ========================================================================== + + /** + * Create a TCP socket + * @returns Socket ID (>0), or -1 on error + */ + sk_tcp_create: (): number => { + if (!capabilities.rawSockets) { + debug(`[${pluginId}] rawSockets capability not granted`) + return -1 + } + return tcpSocketManager.createSocket(pluginId) + }, + + /** + * Connect TCP socket to remote host + * @param socketId - Socket ID from sk_tcp_create + * @param addrPtr - Pointer to host address string + * @param addrLen - Length of address string + * @param port - Remote port number + * @returns 0 if connection initiated, -1 on error + */ + sk_tcp_connect: ( + socketId: number, + addrPtr: number, + addrLen: number, + port: number + ): number => { + if (!capabilities.rawSockets) return -1 + try { + const address = readUtf8String(addrPtr, addrLen) + debug(`[${pluginId}] TCP connecting to ${address}:${port}`) + return tcpSocketManager.connect(socketId, address, port) + } catch (error) { + debug(`[${pluginId}] TCP connect error: ${error}`) + return -1 + } + }, + + /** + * Check if TCP socket is connected + * @param socketId - Socket ID + * @returns 1 if connected, 0 if not, -1 if socket not found + */ + sk_tcp_connected: (socketId: number): number => { + if (!capabilities.rawSockets) return -1 + return tcpSocketManager.isConnected(socketId) + }, + + /** + * Set TCP socket buffering mode + * @param socketId - Socket ID + * @param lineBuffering - 1 for line-buffered (text), 0 for raw (binary) + * @returns 0 on success, -1 on error + */ + sk_tcp_set_line_buffering: ( + socketId: number, + lineBuffering: number + ): number => { + if (!capabilities.rawSockets) return -1 + return tcpSocketManager.setLineBuffering(socketId, lineBuffering !== 0) + }, + + /** + * Send data via TCP + * @param socketId - Socket ID + * @param dataPtr - Data pointer + * @param dataLen - Data length + * @returns Bytes sent, or -1 on error + */ + sk_tcp_send: ( + socketId: number, + dataPtr: number, + dataLen: number + ): number => { + if (!capabilities.rawSockets) return -1 + try { + if (!memoryRef.current) return -1 + const data = Buffer.from( + new Uint8Array(memoryRef.current.buffer, dataPtr, dataLen) + ) + + // Send is async, but we return immediately + tcpSocketManager.send(socketId, data).catch((err) => { + debug(`[${pluginId}] Async TCP send error: ${err}`) + }) + return dataLen + } catch (error) { + debug(`[${pluginId}] TCP send error: ${error}`) + return -1 + } + }, + + /** + * Receive a complete line from TCP socket (non-blocking) + * Only works in line-buffered mode + * @param socketId - Socket ID + * @param bufPtr - Buffer to write line into (without line ending) + * @param bufMaxLen - Maximum buffer size + * @returns Bytes received, 0 if no complete line, -1 on error + */ + sk_tcp_recv_line: ( + socketId: number, + bufPtr: number, + bufMaxLen: number + ): number => { + if (!capabilities.rawSockets) return -1 + try { + const line = tcpSocketManager.receiveLine(socketId) + if (!line) { + return 0 // No complete line available + } + + if (!memoryRef.current) return -1 + const memory = memoryRef.current + const memView = new Uint8Array(memory.buffer) + + // Convert line to bytes and copy to buffer + const lineBytes = Buffer.from(line, 'utf8') + const bytesToCopy = Math.min(lineBytes.length, bufMaxLen) + memView.set(lineBytes.slice(0, bytesToCopy), bufPtr) + + return bytesToCopy + } catch (error) { + debug(`[${pluginId}] TCP recv line error: ${error}`) + return -1 + } + }, + + /** + * Receive raw data from TCP socket (non-blocking) + * Only works in raw mode + * @param socketId - Socket ID + * @param bufPtr - Buffer to write data into + * @param bufMaxLen - Maximum buffer size + * @returns Bytes received, 0 if no data, -1 on error + */ + sk_tcp_recv_raw: ( + socketId: number, + bufPtr: number, + bufMaxLen: number + ): number => { + if (!capabilities.rawSockets) return -1 + try { + const data = tcpSocketManager.receiveRaw(socketId) + if (!data) { + return 0 // No data available + } + + if (!memoryRef.current) return -1 + const memory = memoryRef.current + const memView = new Uint8Array(memory.buffer) + + const bytesToCopy = Math.min(data.length, bufMaxLen) + memView.set(data.slice(0, bytesToCopy), bufPtr) + + return bytesToCopy + } catch (error) { + debug(`[${pluginId}] TCP recv raw error: ${error}`) + return -1 + } + }, + + /** + * Get number of buffered items waiting to be received + */ + sk_tcp_pending: (socketId: number): number => { + if (!capabilities.rawSockets) return -1 + return tcpSocketManager.getBufferedCount(socketId) + }, + + /** + * Close a TCP socket + */ + sk_tcp_close: (socketId: number): void => { + if (!capabilities.rawSockets) return + tcpSocketManager.close(socketId) + } + } + + return envImports +} diff --git a/src/wasm/bindings/index.ts b/src/wasm/bindings/index.ts new file mode 100644 index 000000000..84f7ae3dc --- /dev/null +++ b/src/wasm/bindings/index.ts @@ -0,0 +1,9 @@ +/** + * WASM Bindings - Host functions provided to WASM plugins + */ + +export * from './env-imports' +export * from './resource-provider' +export * from './weather-provider' +export * from './signalk-api' +export * from './socket-manager' diff --git a/src/wasm/bindings/radar-provider.ts b/src/wasm/bindings/radar-provider.ts new file mode 100644 index 000000000..30d3e0595 --- /dev/null +++ b/src/wasm/bindings/radar-provider.ts @@ -0,0 +1,952 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Radar Provider Support + * + * Handles radar provider registration and handler invocation for WASM plugins. + * Integrates with Signal K's Radar API at /signalk/v2/api/vessels/self/radars + */ + +import Debug from 'debug' +import { WasmRadarProvider, WasmPluginInstance } from '../types' + +const debug = Debug('signalk:wasm:radar-provider') + +/** + * Registered radar providers from WASM plugins + * Key: pluginId + */ +export const wasmRadarProviders: Map = new Map() + +/** + * Call a WASM radar handler function + * Handles both AssemblyScript and Rust plugins with Asyncify support for async operations + */ +export async function callWasmRadarHandler( + pluginInstance: WasmPluginInstance, + handlerName: string, + requestJson: string +): Promise { + try { + const asLoader = pluginInstance.asLoader + // Use the wrapped exports which have proper WASI initialization + // Fall back to raw instance exports if wrapped exports don't have the handler + const _wrappedExports = pluginInstance.exports as any + const rawExports = pluginInstance.instance?.exports as any + + // Debug: list available exports when handler is not found + if (rawExports) { + const exportNames = Object.keys(rawExports).filter((k) => + k.startsWith('radar_') + ) + debug( + `[${pluginInstance.pluginId}] Looking for ${handlerName}, available radar_ exports: ${exportNames.join(', ')}` + ) + } else { + debug(`[${pluginInstance.pluginId}] No rawExports available`) + } + + if (asLoader && typeof asLoader.exports[handlerName] === 'function') { + // AssemblyScript: allocate string in WASM memory, pass pointer, get string pointer back + // Need to handle Asyncify for handlers that call fetchSync + const requestPtr = asLoader.exports.__newString(requestJson) + + // Set up Asyncify resume handling + let resumePromiseResolve: ((result: string | null) => void) | null = null + const resumePromise = new Promise((resolve) => { + resumePromiseResolve = resolve + }) + + // Store the result pointer from the handler call + let handlerResultPtr: any = null + + if (pluginInstance.setAsyncifyResume) { + pluginInstance.setAsyncifyResume(() => { + debug(`Re-calling ${handlerName} to resume from rewind state`) + const resumeResultPtr = asLoader.exports[handlerName](requestPtr) + const result = asLoader.exports.__getString(resumeResultPtr) + if (resumePromiseResolve) { + resumePromiseResolve(result) + } + return resumeResultPtr + }) + } + + // Call the handler + handlerResultPtr = asLoader.exports[handlerName](requestPtr) + + // Check if we're in Asyncify unwind state + if (typeof asLoader.exports.asyncify_get_state === 'function') { + const state = asLoader.exports.asyncify_get_state() + debug(`Asyncify state after ${handlerName}: ${state}`) + + if (state === 1) { + // State 1 = unwound, waiting for async operation + debug( + `${handlerName} is in unwound state - waiting for async operation to complete` + ) + const result = await resumePromise + debug(`${handlerName} async operation completed`) + if (pluginInstance.setAsyncifyResume) { + pluginInstance.setAsyncifyResume(null) + } + return result + } else { + // Not in async state, clean up + if (pluginInstance.setAsyncifyResume) { + pluginInstance.setAsyncifyResume(null) + } + } + } + + // Normal synchronous return + return asLoader.exports.__getString(handlerResultPtr) + } else if (rawExports && typeof rawExports[handlerName] === 'function') { + // Rust: buffer-based string passing + if (typeof rawExports.allocate !== 'function') { + debug(`Plugin ${pluginInstance.pluginId} missing allocate export`) + return null + } + + const responseMaxLen = 65536 // 64KB response buffer + const responsePtr = rawExports.allocate(responseMaxLen) + const memory = rawExports.memory as WebAssembly.Memory + + let writtenLen: number + + // radar_get_radars takes only output buffer params: (output_ptr, output_len) -> written_len + if (handlerName === 'radar_get_radars') { + writtenLen = rawExports[handlerName](responsePtr, responseMaxLen) + } else { + // Other handlers take request + output: (request_ptr, request_len, response_ptr, response_max_len) -> written_len + const requestBytes = Buffer.from(requestJson, 'utf8') + const requestPtr = rawExports.allocate(requestBytes.length) + + // Write request to WASM memory + const memView = new Uint8Array(memory.buffer) + memView.set(requestBytes, requestPtr) + + writtenLen = rawExports[handlerName]( + requestPtr, + requestBytes.length, + responsePtr, + responseMaxLen + ) + + // Deallocate request buffer + if (typeof rawExports.deallocate === 'function') { + rawExports.deallocate(requestPtr, requestBytes.length) + } + } + + // Read response from WASM memory + const responseBytes = new Uint8Array( + memory.buffer, + responsePtr, + writtenLen + ) + const responseJson = new TextDecoder('utf-8').decode(responseBytes) + + // Deallocate response buffer + if (typeof rawExports.deallocate === 'function') { + rawExports.deallocate(responsePtr, responseMaxLen) + } + + return responseJson + } + + debug( + `Handler ${handlerName} not found in plugin ${pluginInstance.pluginId}` + ) + return null + } catch (error) { + debug(`Error calling radar handler ${handlerName}: ${error}`) + return null + } +} + +/** + * Update radar provider references with a newly loaded plugin instance + */ +export function updateRadarProviderInstance( + pluginId: string, + pluginInstance: WasmPluginInstance +): void { + const provider = wasmRadarProviders.get(pluginId) + if (provider) { + provider.pluginInstance = pluginInstance + debug(`Updated radar provider ${pluginId} with plugin instance`) + } +} + +/** + * Clean up radar provider registrations for a plugin + * @param pluginId The plugin ID + * @param app The Signal K app (optional, if provided will also unregister from RadarApi) + */ +export function cleanupRadarProviders(pluginId: string, app?: any): void { + if (wasmRadarProviders.has(pluginId)) { + debug(`Removing radar provider registration: ${pluginId}`) + wasmRadarProviders.delete(pluginId) + } + + // Also unregister from Signal K RadarApi + if (app && app.radarApi && typeof app.radarApi.unRegister === 'function') { + try { + app.radarApi.unRegister(pluginId) + debug(`Unregistered ${pluginId} from RadarApi`) + } catch (error) { + debug(`Error unregistering from RadarApi: ${error}`) + } + } +} + +/** + * Create the sk_register_radar_provider host binding + * + * WASM plugins call this to register as a radar provider. + * The plugin must export handler functions: + * - radar_get_radars() -> JSON array of radar IDs + * - radar_get_radar_info(requestJson) -> RadarInfo JSON + * - radar_set_power(requestJson) -> boolean success + * - radar_set_range(requestJson) -> boolean success + * - radar_set_gain(requestJson) -> boolean success + * - radar_set_controls(requestJson) -> boolean success + */ +export function createRadarProviderBinding( + pluginId: string, + capabilities: { radarProvider?: boolean }, + app: any, + readUtf8String: (ptr: number, len: number) => string +): (namePtr: number, nameLen: number) => number { + return (namePtr: number, nameLen: number): number => { + try { + const providerName = readUtf8String(namePtr, nameLen) + debug(`[${pluginId}] Registering as radar provider: ${providerName}`) + + // Check if plugin has radarProvider capability + if (!capabilities.radarProvider) { + debug(`[${pluginId}] radarProvider capability not granted`) + return 0 // Failure + } + + // Check if app and radarApi are available + if (!app || !app.radarApi) { + debug(`[${pluginId}] app.radarApi not available`) + return 0 + } + + // Store the registration (we'll update the pluginInstance reference after instance creation) + wasmRadarProviders.set(pluginId, { + pluginId, + providerName, + pluginInstance: null // Will be set after full instance creation + }) + + // Create RadarProvider object that calls into WASM handlers + const radarProvider = { + name: providerName, + methods: { + pluginId: pluginId, + + /** + * Get list of radar IDs this provider manages + */ + getRadars: async (): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return [] + } + + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_get_radars', + '{}' + ) + + if (result) { + try { + return JSON.parse(result) + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_get_radars response: ${e}` + ) + return [] + } + } + return [] + }, + + /** + * Get radar info for a specific radar + * @param radarId The radar ID + */ + getRadarInfo: async (radarId: string): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return null + } + + const requestJson = JSON.stringify({ radarId }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_get_radar_info', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_get_radar_info response: ${e}` + ) + return null + } + } + return null + }, + + /** + * Set radar power state + * @param radarId The radar ID + * @param state Power state + */ + setPower: async ( + radarId: string, + state: string + ): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return false + } + + const requestJson = JSON.stringify({ radarId, state }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_set_power', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) === true + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_set_power response: ${e}` + ) + return false + } + } + return false + }, + + /** + * Set radar range + * @param radarId The radar ID + * @param range Range in meters + */ + setRange: async ( + radarId: string, + range: number + ): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return false + } + + const requestJson = JSON.stringify({ radarId, range }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_set_range', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) === true + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_set_range response: ${e}` + ) + return false + } + } + return false + }, + + /** + * Set radar gain + * @param radarId The radar ID + * @param gain Gain settings + */ + setGain: async ( + radarId: string, + gain: { auto: boolean; value?: number } + ): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return false + } + + const requestJson = JSON.stringify({ radarId, gain }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_set_gain', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) === true + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_set_gain response: ${e}` + ) + return false + } + } + return false + }, + + /** + * Set radar sea clutter + * @param radarId The radar ID + * @param sea Sea clutter settings + */ + setSea: async ( + radarId: string, + sea: { auto: boolean; value?: number } + ): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return false + } + + const requestJson = JSON.stringify({ radarId, sea }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_set_sea', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) === true + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_set_sea response: ${e}` + ) + return false + } + } + return false + }, + + /** + * Set radar rain clutter + * @param radarId The radar ID + * @param rain Rain clutter settings + */ + setRain: async ( + radarId: string, + rain: { auto: boolean; value?: number } + ): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return false + } + + const requestJson = JSON.stringify({ radarId, rain }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_set_rain', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) === true + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_set_rain response: ${e}` + ) + return false + } + } + return false + }, + + /** + * Set multiple radar controls at once + * @param radarId The radar ID + * @param controls Controls to update + */ + setControls: async ( + radarId: string, + controls: any + ): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return false + } + + const requestJson = JSON.stringify({ radarId, controls }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_set_controls', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) === true + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_set_controls response: ${e}` + ) + return false + } + } + return false + }, + + /** + * Get capability manifest for a radar + * @param radarId The radar ID + */ + getCapabilities: async (radarId: string): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return null + } + + const requestJson = JSON.stringify({ radarId }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_get_capabilities', + requestJson + ) + + if (result) { + try { + const parsed = JSON.parse(result) + if (parsed.error) { + debug( + `[${pluginId}] radar_get_capabilities error: ${parsed.error}` + ) + return null + } + return parsed + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_get_capabilities response: ${e}` + ) + return null + } + } + return null + }, + + /** + * Get current state + * @param radarId The radar ID + */ + getState: async (radarId: string): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return null + } + + const requestJson = JSON.stringify({ radarId }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_get_state', + requestJson + ) + + if (result) { + try { + const parsed = JSON.parse(result) + if (parsed.error) { + debug(`[${pluginId}] radar_get_state error: ${parsed.error}`) + return null + } + return parsed + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_get_state response: ${e}` + ) + return null + } + } + return null + }, + + /** + * Get a single control value + * @param radarId The radar ID + * @param controlId The control ID + */ + getControl: async ( + radarId: string, + controlId: string + ): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return null + } + + const requestJson = JSON.stringify({ radarId, controlId }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_get_control', + requestJson + ) + + if (result) { + try { + const parsed = JSON.parse(result) + if (parsed.error) { + debug( + `[${pluginId}] radar_get_control error: ${parsed.error}` + ) + return null + } + return parsed + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_get_control response: ${e}` + ) + return null + } + } + return null + }, + + /** + * Set a single control value + * @param radarId The radar ID + * @param controlId The control ID + * @param value The value to set + */ + setControl: async ( + radarId: string, + controlId: string, + value: any + ): Promise<{ success: boolean; error?: string }> => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return { success: false, error: 'Provider not ready' } + } + + const requestJson = JSON.stringify({ radarId, controlId, value }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_set_control', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_set_control response: ${e}` + ) + return { success: false, error: 'Invalid response' } + } + } + return { success: false, error: 'No response' } + }, + + // ============================================ + // ARPA Target Methods + // ============================================ + + /** + * Get all tracked ARPA targets + * @param radarId The radar ID + */ + getTargets: async (radarId: string): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return null + } + + const requestJson = JSON.stringify({ radarId }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_get_targets', + requestJson + ) + + if (result) { + try { + const parsed = JSON.parse(result) + if (parsed.error) { + debug( + `[${pluginId}] radar_get_targets error: ${parsed.error}` + ) + return null + } + return parsed + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_get_targets response: ${e}` + ) + return null + } + } + return null + }, + + /** + * Manually acquire a target at the specified position + * @param radarId The radar ID + * @param bearing Bearing in degrees + * @param distance Distance in meters + */ + acquireTarget: async ( + radarId: string, + bearing: number, + distance: number + ): Promise<{ + success: boolean + targetId?: number + error?: string + }> => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return { success: false, error: 'Provider not ready' } + } + + const requestJson = JSON.stringify({ radarId, bearing, distance }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_acquire_target', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_acquire_target response: ${e}` + ) + return { success: false, error: 'Invalid response' } + } + } + return { success: false, error: 'No response' } + }, + + /** + * Cancel tracking of a target + * @param radarId The radar ID + * @param targetId The target ID to cancel + */ + cancelTarget: async ( + radarId: string, + targetId: number + ): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return false + } + + const requestJson = JSON.stringify({ radarId, targetId }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_cancel_target', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) === true + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_cancel_target response: ${e}` + ) + return false + } + } + return false + }, + + /** + * Get ARPA settings + * @param radarId The radar ID + */ + getArpaSettings: async (radarId: string): Promise => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return null + } + + const requestJson = JSON.stringify({ radarId }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_get_arpa_settings', + requestJson + ) + + if (result) { + try { + const parsed = JSON.parse(result) + if (parsed.error) { + debug( + `[${pluginId}] radar_get_arpa_settings error: ${parsed.error}` + ) + return null + } + return parsed + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_get_arpa_settings response: ${e}` + ) + return null + } + } + return null + }, + + /** + * Update ARPA settings + * @param radarId The radar ID + * @param settings Partial settings to update + */ + setArpaSettings: async ( + radarId: string, + settings: any + ): Promise<{ success: boolean; error?: string }> => { + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return { success: false, error: 'Provider not ready' } + } + + const requestJson = JSON.stringify({ radarId, settings }) + const result = await callWasmRadarHandler( + provider.pluginInstance, + 'radar_set_arpa_settings', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) + } catch (e) { + debug( + `[${pluginId}] Failed to parse radar_set_arpa_settings response: ${e}` + ) + return { success: false, error: 'Invalid response' } + } + } + return { success: false, error: 'No response' } + } + } + } + + // Register with Signal K RadarApi + app.radarApi.register(pluginId, radarProvider) + + debug( + `[${pluginId}] Successfully registered as radar provider: ${providerName}` + ) + return 1 // Success + } catch (error) { + debug(`Plugin register radar provider error: ${error}`) + return 0 + } + } +} + +/** + * Create the sk_radar_emit_spokes host binding + * + * Convenience wrapper for radar plugins to emit binary spoke data. + * Maps to sk_emit_binary_stream with "radars/{radarId}" stream ID format. + * + * @param pluginId - Plugin identifier + * @param capabilities - Plugin capabilities + * @param app - SignalK application instance + * @param readUtf8String - Function to read UTF-8 strings from WASM memory + * @param readBinaryData - Function to read binary data from WASM memory + * @returns FFI binding function + */ +export function createRadarEmitSpokesBinding( + pluginId: string, + capabilities: { radarProvider?: boolean }, + app: any, + readUtf8String: (ptr: number, len: number) => string, + readBinaryData: (ptr: number, len: number) => Buffer +): ( + radarIdPtr: number, + radarIdLen: number, + spokeDataPtr: number, + spokeDataLen: number +) => number { + return ( + radarIdPtr: number, + radarIdLen: number, + spokeDataPtr: number, + spokeDataLen: number + ): number => { + try { + // Check radar provider capability + if (!capabilities.radarProvider) { + debug(`[${pluginId}] radarProvider capability not granted`) + return 0 + } + + // Extract radar ID and spoke data from WASM memory + const radarId = readUtf8String(radarIdPtr, radarIdLen) + const spokeData = readBinaryData(spokeDataPtr, spokeDataLen) + + // Only log periodically to avoid flooding logs (every ~1000 calls) + if (Math.random() < 0.001) { + debug( + `[${pluginId}] sk_radar_emit_spokes: radarId="${radarId}", ` + + `dataLen=${spokeDataLen} bytes` + ) + } + + // Validate radar belongs to this plugin + const provider = wasmRadarProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Radar provider instance not ready`) + return 0 + } + + // Use general binary stream with radar stream ID format + const streamId = `radars/${radarId}` + if (app && app.binaryStreamManager) { + app.binaryStreamManager.emitData(streamId, spokeData) + return 1 // Success + } else { + debug(`[${pluginId}] Binary stream manager not available`) + return 0 // Failure + } + } catch (error) { + debug(`[${pluginId}] sk_radar_emit_spokes error: ${error}`) + return 0 // Failure + } + } +} diff --git a/src/wasm/bindings/resource-provider.ts b/src/wasm/bindings/resource-provider.ts new file mode 100644 index 000000000..ad7cc601e --- /dev/null +++ b/src/wasm/bindings/resource-provider.ts @@ -0,0 +1,262 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Resource Provider Support + * + * Handles resource provider registration and handler invocation for WASM plugins + */ + +import Debug from 'debug' +import { WasmResourceProvider, WasmPluginInstance } from '../types' + +const debug = Debug('signalk:wasm:resource-provider') + +/** + * Registered resource providers from WASM plugins + * Key: pluginId:resourceType + */ +export const wasmResourceProviders: Map = + new Map() + +/** + * Call a WASM resource handler function + * Handles both AssemblyScript and Rust plugins + */ +export function callWasmResourceHandler( + pluginInstance: WasmPluginInstance, + handlerName: string, + requestJson: string +): string | null { + try { + const asLoader = pluginInstance.asLoader + const rawExports = pluginInstance.instance?.exports as any + + if (asLoader && typeof asLoader.exports[handlerName] === 'function') { + // AssemblyScript: allocate string in WASM memory, pass pointer, get string pointer back + const requestPtr = asLoader.exports.__newString(requestJson) + const resultPtr = asLoader.exports[handlerName](requestPtr) + return asLoader.exports.__getString(resultPtr) + } else if (rawExports && typeof rawExports[handlerName] === 'function') { + // Rust: buffer-based string passing + if (typeof rawExports.allocate !== 'function') { + debug(`Plugin ${pluginInstance.pluginId} missing allocate export`) + return null + } + + const requestBytes = Buffer.from(requestJson, 'utf8') + const requestPtr = rawExports.allocate(requestBytes.length) + const responseMaxLen = 65536 // 64KB response buffer + const responsePtr = rawExports.allocate(responseMaxLen) + + // Write request to WASM memory + const memory = rawExports.memory as WebAssembly.Memory + const memView = new Uint8Array(memory.buffer) + memView.set(requestBytes, requestPtr) + + // Call handler: (request_ptr, request_len, response_ptr, response_max_len) -> written_len + const writtenLen = rawExports[handlerName]( + requestPtr, + requestBytes.length, + responsePtr, + responseMaxLen + ) + + // Read response from WASM memory + const responseBytes = new Uint8Array( + memory.buffer, + responsePtr, + writtenLen + ) + const responseJson = new TextDecoder('utf-8').decode(responseBytes) + + // Deallocate buffers + if (typeof rawExports.deallocate === 'function') { + rawExports.deallocate(requestPtr, requestBytes.length) + rawExports.deallocate(responsePtr, responseMaxLen) + } + + return responseJson + } + + debug( + `Handler ${handlerName} not found in plugin ${pluginInstance.pluginId}` + ) + return null + } catch (error) { + debug(`Error calling resource handler ${handlerName}: ${error}`) + return null + } +} + +/** + * Update resource provider references with a newly loaded plugin instance + */ +export function updateResourceProviderInstance( + pluginId: string, + pluginInstance: WasmPluginInstance +): void { + if (wasmResourceProviders && wasmResourceProviders.size > 0) { + wasmResourceProviders.forEach((provider, key) => { + if (provider.pluginId === pluginId) { + provider.pluginInstance = pluginInstance + debug(`Updated resource provider ${key} with plugin instance`) + } + }) + } +} + +/** + * Clean up resource provider registrations for a plugin + * @param pluginId The plugin ID + * @param app The Signal K app (optional, if provided will also unregister from ResourcesApi) + */ +export function cleanupResourceProviders(pluginId: string, app?: any): void { + const keysToDelete: string[] = [] + wasmResourceProviders.forEach((provider, key) => { + if (provider.pluginId === pluginId) { + keysToDelete.push(key) + } + }) + keysToDelete.forEach((key) => { + debug(`Removing resource provider registration: ${key}`) + wasmResourceProviders.delete(key) + }) + + // Also unregister from Signal K ResourcesApi + if ( + app && + app.resourcesApi && + typeof app.resourcesApi.unRegister === 'function' + ) { + try { + app.resourcesApi.unRegister(pluginId) + debug(`Unregistered ${pluginId} from ResourcesApi`) + } catch (error) { + debug(`Error unregistering from ResourcesApi: ${error}`) + } + } +} + +/** + * Create the sk_register_resource_provider host binding + */ +export function createResourceProviderBinding( + pluginId: string, + capabilities: { resourceProvider?: boolean }, + app: any, + readUtf8String: (ptr: number, len: number) => string +): (typePtr: number, typeLen: number) => number { + return (typePtr: number, typeLen: number): number => { + try { + const resourceType = readUtf8String(typePtr, typeLen) + debug( + `[${pluginId}] Registering as resource provider for: ${resourceType}` + ) + + // Check if plugin has resourceProvider capability + if (!capabilities.resourceProvider) { + debug(`[${pluginId}] resourceProvider capability not granted`) + return 0 // Failure + } + + // Check if app and resourcesApi are available + if (!app || !app.resourcesApi) { + debug(`[${pluginId}] app.resourcesApi not available`) + return 0 + } + + // Store the registration (we'll update the pluginInstance reference after instance creation) + const key = `${pluginId}:${resourceType}` + wasmResourceProviders.set(key, { + pluginId, + resourceType, + pluginInstance: null // Will be set after full instance creation + }) + + // Create wrapper methods that call into WASM + // Note: resourceType is captured from closure scope + const providerMethods = { + listResources: async (query: { + [key: string]: any + }): Promise<{ [id: string]: any }> => { + const provider = wasmResourceProviders.get(key) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Resource provider instance not ready`) + return {} + } + + // Include resourceType so WASM knows which type to list + const queryJson = JSON.stringify({ ...query, resourceType }) + const result = callWasmResourceHandler( + provider.pluginInstance, + 'resources_list_resources', + queryJson + ) + return result ? JSON.parse(result) : {} + }, + getResource: async (id: string, property?: string): Promise => { + const provider = wasmResourceProviders.get(key) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Resource provider instance not ready`) + return {} + } + + // Include resourceType so WASM knows which storage to search + const requestJson = JSON.stringify({ id, property, resourceType }) + const result = callWasmResourceHandler( + provider.pluginInstance, + 'resources_get_resource', + requestJson + ) + return result ? JSON.parse(result) : {} + }, + setResource: async ( + id: string, + value: { [key: string]: any } + ): Promise => { + const provider = wasmResourceProviders.get(key) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Resource provider instance not ready`) + return + } + + // Include resourceType so WASM knows which storage to update + const requestJson = JSON.stringify({ id, value, resourceType }) + callWasmResourceHandler( + provider.pluginInstance, + 'resources_set_resource', + requestJson + ) + }, + deleteResource: async (id: string): Promise => { + const provider = wasmResourceProviders.get(key) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Resource provider instance not ready`) + return + } + + // Include resourceType so WASM knows which storage to delete from + const requestJson = JSON.stringify({ id, resourceType }) + callWasmResourceHandler( + provider.pluginInstance, + 'resources_delete_resource', + requestJson + ) + } + } + + // Register with Signal K ResourcesApi + app.resourcesApi.register(pluginId, { + type: resourceType, + methods: providerMethods + }) + + debug( + `[${pluginId}] Successfully registered as ${resourceType} resource provider` + ) + return 1 // Success + } catch (error) { + debug(`Plugin register resource provider error: ${error}`) + return 0 + } + } +} diff --git a/src/wasm/bindings/signalk-api.ts b/src/wasm/bindings/signalk-api.ts new file mode 100644 index 000000000..1264cee30 --- /dev/null +++ b/src/wasm/bindings/signalk-api.ts @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Signal K API for Component Model plugins + * + * Provides the Signal K API interface for jco-transpiled Component Model plugins + */ + +import Debug from 'debug' + +const debug = Debug('signalk:wasm:signalk-api') + +/** + * Create Signal K API imports for a Component Model plugin + * + * Component Model plugins (e.g., from .NET or Rust wit-bindgen) use + * a different import mechanism than WASI P1 plugins. This provides + * the same Signal K functionality in a format they can use. + */ +export function createComponentSignalkApi(pluginId: string, app?: any) { + return { + // camelCase versions (for wit-bindgen style) + skDebug: (message: string) => { + debug(`[${pluginId}] ${message}`) + }, + skSetStatus: (message: string) => { + debug(`[${pluginId}] Status: ${message}`) + if (app && app.setPluginStatus) { + app.setPluginStatus(pluginId, message) + } + }, + skSetError: (message: string) => { + debug(`[${pluginId}] Error: ${message}`) + if (app && app.setPluginError) { + app.setPluginError(pluginId, message) + } + }, + skHandleMessage: (deltaJson: string) => { + debug(`[${pluginId}] Emitting delta: ${deltaJson.substring(0, 200)}...`) + if (app && app.handleMessage) { + try { + const delta = JSON.parse(deltaJson) + app.handleMessage(pluginId, delta) + } catch (error) { + debug(`Failed to parse delta JSON: ${error}`) + } + } + }, + + // kebab-case versions (for WIT interface style) + 'sk-debug': (message: string) => { + debug(`[${pluginId}] ${message}`) + }, + 'sk-set-status': (message: string) => { + debug(`[${pluginId}] Status: ${message}`) + if (app && app.setPluginStatus) { + app.setPluginStatus(pluginId, message) + } + }, + 'sk-set-error': (message: string) => { + debug(`[${pluginId}] Error: ${message}`) + if (app && app.setPluginError) { + app.setPluginError(pluginId, message) + } + }, + 'sk-handle-message': (deltaJson: string) => { + debug(`[${pluginId}] Emitting delta: ${deltaJson.substring(0, 200)}...`) + if (app && app.handleMessage) { + try { + const delta = JSON.parse(deltaJson) + app.handleMessage(pluginId, delta) + } catch (error) { + debug(`Failed to parse delta JSON: ${error}`) + } + } + } + } +} diff --git a/src/wasm/bindings/socket-manager.ts b/src/wasm/bindings/socket-manager.ts new file mode 100644 index 000000000..b43fc7389 --- /dev/null +++ b/src/wasm/bindings/socket-manager.ts @@ -0,0 +1,830 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * WASM Socket Manager + * + * Manages UDP and TCP sockets for WASM plugins that need raw network access + * (e.g., radar plugins, NMEA receivers, etc.) + * + * Uses Node.js dgram and net modules, bridged to WASM via FFI + */ + +import * as dgram from 'dgram' +import * as net from 'net' +import Debug from 'debug' + +const debug = Debug('signalk:wasm:sockets') + +/** + * Buffered datagram for non-blocking receive + */ +interface BufferedDatagram { + data: Buffer + address: string + port: number + timestamp: number +} + +/** + * Pending socket option to apply after bind + */ +interface PendingOption { + type: + | 'broadcast' + | 'multicastTTL' + | 'multicastLoopback' + | 'joinMulticast' + | 'leaveMulticast' + value: + | boolean + | number + | { multicastAddress: string; interfaceAddress?: string } +} + +/** + * Managed UDP socket with receive buffer + */ +interface ManagedSocket { + socket: dgram.Socket + pluginId: string + bound: boolean + bindPromise: Promise | null + receiveBuffer: BufferedDatagram[] + maxBufferSize: number + multicastGroups: Set + pendingOptions: PendingOption[] +} + +/** + * Socket Manager - singleton for managing plugin sockets + */ +class SocketManager { + private sockets: Map = new Map() + private nextSocketId: number = 1 + + /** + * Create a new UDP socket + * @param pluginId - Plugin that owns the socket + * @param type - Socket type: 'udp4' or 'udp6' + * @returns Socket ID, or -1 on error + */ + createSocket(pluginId: string, type: 'udp4' | 'udp6' = 'udp4'): number { + try { + const socketId = this.nextSocketId++ + const socket = dgram.createSocket({ + type, + reuseAddr: true // Allow multiple plugins to bind to same port + }) + + const managed: ManagedSocket = { + socket, + pluginId, + bound: false, + bindPromise: null, + receiveBuffer: [], + maxBufferSize: 1000, // Max buffered datagrams + multicastGroups: new Set(), + pendingOptions: [] + } + + // Set up message handler to buffer incoming data + socket.on('message', (msg, rinfo) => { + if (managed.receiveBuffer.length >= managed.maxBufferSize) { + // Drop oldest message if buffer full + managed.receiveBuffer.shift() + } + managed.receiveBuffer.push({ + data: Buffer.from(msg), // Copy the buffer + address: rinfo.address, + port: rinfo.port, + timestamp: Date.now() + }) + }) + + socket.on('error', (err) => { + debug(`[${pluginId}] Socket ${socketId} error: ${err.message}`) + }) + + socket.on('close', () => { + debug(`[${pluginId}] Socket ${socketId} closed`) + this.sockets.delete(socketId) + }) + + this.sockets.set(socketId, managed) + debug(`[${pluginId}] Created socket ${socketId} (${type})`) + return socketId + } catch (error) { + debug(`Failed to create socket: ${error}`) + return -1 + } + } + + /** + * Bind socket to a port + * @param socketId - Socket to bind + * @param port - Port number (0 for any available port) + * @param address - Address to bind to (optional, defaults to all interfaces) + * @returns 0 on success, -1 on error + */ + bind(socketId: number, port: number, address?: string): Promise { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`Socket ${socketId} not found`) + return Promise.resolve(-1) + } + + // Store the promise so setBroadcast etc. can wait for it + managed.bindPromise = new Promise((resolve) => { + try { + managed.socket.bind(port, address, () => { + managed.bound = true + const addr = managed.socket.address() + debug( + `[${managed.pluginId}] Socket ${socketId} bound to ${addr.address}:${addr.port}` + ) + + // Apply any pending socket options now that we're bound + for (const option of managed.pendingOptions) { + try { + if (option.type === 'broadcast') { + managed.socket.setBroadcast(option.value as boolean) + debug( + `[${managed.pluginId}] Applied deferred setBroadcast(${option.value})` + ) + } else if (option.type === 'multicastTTL') { + managed.socket.setMulticastTTL(option.value as number) + debug( + `[${managed.pluginId}] Applied deferred setMulticastTTL(${option.value})` + ) + } else if (option.type === 'multicastLoopback') { + managed.socket.setMulticastLoopback(option.value as boolean) + debug( + `[${managed.pluginId}] Applied deferred setMulticastLoopback(${option.value})` + ) + } else if (option.type === 'joinMulticast') { + const { multicastAddress, interfaceAddress } = option.value as { + multicastAddress: string + interfaceAddress?: string + } + if (interfaceAddress) { + managed.socket.addMembership( + multicastAddress, + interfaceAddress + ) + } else { + managed.socket.addMembership(multicastAddress) + } + managed.multicastGroups.add(multicastAddress) + debug( + `[${managed.pluginId}] Applied deferred joinMulticast(${multicastAddress})` + ) + } else if (option.type === 'leaveMulticast') { + const { multicastAddress, interfaceAddress } = option.value as { + multicastAddress: string + interfaceAddress?: string + } + if (interfaceAddress) { + managed.socket.dropMembership( + multicastAddress, + interfaceAddress + ) + } else { + managed.socket.dropMembership(multicastAddress) + } + managed.multicastGroups.delete(multicastAddress) + debug( + `[${managed.pluginId}] Applied deferred leaveMulticast(${multicastAddress})` + ) + } + } catch (optionError) { + debug( + `[${managed.pluginId}] Error applying deferred option ${option.type}: ${optionError}` + ) + } + } + managed.pendingOptions = [] + + resolve(0) + }) + } catch (error) { + debug(`[${managed.pluginId}] Bind error: ${error}`) + resolve(-1) + } + }) + + return managed.bindPromise + } + + /** + * Join a multicast group + * @param socketId - Socket to use + * @param multicastAddress - Multicast group address (e.g., "239.254.2.0") + * @param interfaceAddress - Interface address to use (optional) + * @returns 0 on success, -1 on error + */ + joinMulticast( + socketId: number, + multicastAddress: string, + interfaceAddress?: string + ): number { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`Socket ${socketId} not found`) + return -1 + } + + // If socket is not yet bound, defer the multicast join until bind completes + if (!managed.bound) { + debug( + `[${managed.pluginId}] Deferring joinMulticast(${multicastAddress}) until socket is bound` + ) + managed.pendingOptions.push({ + type: 'joinMulticast', + value: { multicastAddress, interfaceAddress } + }) + return 0 + } + + try { + if (interfaceAddress) { + managed.socket.addMembership(multicastAddress, interfaceAddress) + } else { + managed.socket.addMembership(multicastAddress) + } + managed.multicastGroups.add(multicastAddress) + debug( + `[${managed.pluginId}] Socket ${socketId} joined multicast ${multicastAddress}` + ) + return 0 + } catch (error) { + debug(`[${managed.pluginId}] Join multicast error: ${error}`) + return -1 + } + } + + /** + * Leave a multicast group + * @param socketId - Socket to use + * @param multicastAddress - Multicast group address + * @param interfaceAddress - Interface address (optional) + * @returns 0 on success, -1 on error + */ + leaveMulticast( + socketId: number, + multicastAddress: string, + interfaceAddress?: string + ): number { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`Socket ${socketId} not found`) + return -1 + } + + // If socket is not yet bound, defer the multicast leave until bind completes + if (!managed.bound) { + debug( + `[${managed.pluginId}] Deferring leaveMulticast(${multicastAddress}) until socket is bound` + ) + managed.pendingOptions.push({ + type: 'leaveMulticast', + value: { multicastAddress, interfaceAddress } + }) + return 0 + } + + try { + if (interfaceAddress) { + managed.socket.dropMembership(multicastAddress, interfaceAddress) + } else { + managed.socket.dropMembership(multicastAddress) + } + managed.multicastGroups.delete(multicastAddress) + debug( + `[${managed.pluginId}] Socket ${socketId} left multicast ${multicastAddress}` + ) + return 0 + } catch (error) { + debug(`[${managed.pluginId}] Leave multicast error: ${error}`) + return -1 + } + } + + /** + * Set socket options + */ + setMulticastTTL(socketId: number, ttl: number): number { + const managed = this.sockets.get(socketId) + if (!managed) return -1 + + // If socket is not yet bound, defer the option + if (!managed.bound) { + debug( + `[${managed.pluginId}] Deferring setMulticastTTL(${ttl}) until socket is bound` + ) + managed.pendingOptions.push({ type: 'multicastTTL', value: ttl }) + return 0 + } + + try { + managed.socket.setMulticastTTL(ttl) + return 0 + } catch (error) { + debug(`[${managed.pluginId}] setMulticastTTL error: ${error}`) + return -1 + } + } + + setMulticastLoopback(socketId: number, enabled: boolean): number { + const managed = this.sockets.get(socketId) + if (!managed) return -1 + + // If socket is not yet bound, defer the option + if (!managed.bound) { + debug( + `[${managed.pluginId}] Deferring setMulticastLoopback(${enabled}) until socket is bound` + ) + managed.pendingOptions.push({ type: 'multicastLoopback', value: enabled }) + return 0 + } + + try { + managed.socket.setMulticastLoopback(enabled) + return 0 + } catch (error) { + debug(`[${managed.pluginId}] setMulticastLoopback error: ${error}`) + return -1 + } + } + + setBroadcast(socketId: number, enabled: boolean): number { + const managed = this.sockets.get(socketId) + if (!managed) return -1 + + // If socket is not yet bound, defer the option + if (!managed.bound) { + debug( + `[${managed.pluginId}] Deferring setBroadcast(${enabled}) until socket is bound` + ) + managed.pendingOptions.push({ type: 'broadcast', value: enabled }) + return 0 + } + + try { + managed.socket.setBroadcast(enabled) + return 0 + } catch (error) { + debug(`[${managed.pluginId}] setBroadcast error: ${error}`) + return -1 + } + } + + /** + * Send data via UDP + * @param socketId - Socket to use + * @param data - Data to send + * @param address - Destination address + * @param port - Destination port + * @returns Bytes sent, or -1 on error + */ + send( + socketId: number, + data: Buffer, + address: string, + port: number + ): Promise { + return new Promise((resolve) => { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`Socket ${socketId} not found`) + resolve(-1) + return + } + + managed.socket.send(data, port, address, (err, bytes) => { + if (err) { + debug(`[${managed.pluginId}] Send error: ${err}`) + resolve(-1) + } else { + resolve(bytes) + } + }) + }) + } + + /** + * Receive data from buffer (non-blocking) + * @param socketId - Socket to receive from + * @returns Buffered datagram, or null if buffer empty + */ + receive(socketId: number): BufferedDatagram | null { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`Socket ${socketId} not found`) + return null + } + + return managed.receiveBuffer.shift() || null + } + + /** + * Get number of buffered datagrams + */ + getBufferedCount(socketId: number): number { + const managed = this.sockets.get(socketId) + return managed ? managed.receiveBuffer.length : 0 + } + + /** + * Close a socket + * @param socketId - Socket to close + */ + close(socketId: number): void { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`Socket ${socketId} not found`) + return + } + + try { + // Leave all multicast groups first + for (const group of managed.multicastGroups) { + try { + managed.socket.dropMembership(group) + } catch (e) { + // Ignore errors when leaving groups during close + } + } + + managed.socket.close() + this.sockets.delete(socketId) + debug(`[${managed.pluginId}] Socket ${socketId} closed`) + } catch (error) { + debug(`[${managed.pluginId}] Close error: ${error}`) + } + } + + /** + * Close all sockets for a plugin (cleanup on plugin stop) + */ + closeAllForPlugin(pluginId: string): void { + const toClose: number[] = [] + for (const [id, managed] of this.sockets) { + if (managed.pluginId === pluginId) { + toClose.push(id) + } + } + for (const id of toClose) { + this.close(id) + } + debug(`[${pluginId}] Closed ${toClose.length} sockets`) + } + + /** + * Get socket statistics + */ + getStats(): { + totalSockets: number + socketsPerPlugin: Record + } { + const socketsPerPlugin: Record = {} + for (const managed of this.sockets.values()) { + socketsPerPlugin[managed.pluginId] = + (socketsPerPlugin[managed.pluginId] || 0) + 1 + } + return { + totalSockets: this.sockets.size, + socketsPerPlugin + } + } +} + +// Export singleton instance +export const socketManager = new SocketManager() + +// ============================================================================= +// TCP Socket Manager +// ============================================================================= + +/** + * Managed TCP socket with line-buffered receive + */ +interface ManagedTcpSocket { + socket: net.Socket + pluginId: string + connected: boolean + connecting: boolean + receiveBuffer: string[] // Line-buffered for protocol parsing + rawBuffer: Buffer[] // Raw data buffer for binary protocols + partialLine: string // Incomplete line data + maxBufferSize: number + error: string | null + useLineBuffering: boolean // If false, use raw buffering +} + +/** + * TCP Socket Manager - manages TCP connections for WASM plugins + * + * Key differences from UDP: + * - Connection-oriented (connect before send) + * - Line-buffered receive (splits on \r\n or \n) + */ +class TcpSocketManager { + private sockets: Map = new Map() + private nextSocketId: number = 1 + + /** + * Create a new TCP socket + * @param pluginId - Plugin that owns the socket + * @returns Socket ID, or -1 on error + */ + createSocket(pluginId: string): number { + try { + const socketId = this.nextSocketId++ + const socket = new net.Socket() + + const managed: ManagedTcpSocket = { + socket, + pluginId, + connected: false, + connecting: false, + receiveBuffer: [], + rawBuffer: [], + partialLine: '', + maxBufferSize: 1000, + error: null, + useLineBuffering: true // Default to line buffering + } + + // Set up data handler + socket.on('data', (data: Buffer) => { + if (managed.useLineBuffering) { + // Line-buffered mode for text protocols + managed.partialLine += data.toString() + + // Split on line endings (\r\n or \n) + const lines = managed.partialLine.split(/\r?\n/) + + // Last element is either empty (if data ended with newline) or partial + managed.partialLine = lines.pop() || '' + + // Add complete lines to buffer + for (const line of lines) { + if (line.length > 0) { + if (managed.receiveBuffer.length >= managed.maxBufferSize) { + managed.receiveBuffer.shift() // Drop oldest + } + managed.receiveBuffer.push(line) + } + } + } else { + // Raw mode for binary protocols + if (managed.rawBuffer.length >= managed.maxBufferSize) { + managed.rawBuffer.shift() // Drop oldest + } + managed.rawBuffer.push(Buffer.from(data)) + } + }) + + socket.on('connect', () => { + managed.connected = true + managed.connecting = false + managed.error = null + debug(`[${pluginId}] TCP socket ${socketId} connected`) + }) + + socket.on('error', (err) => { + managed.error = err.message + managed.connected = false + managed.connecting = false + debug(`[${pluginId}] TCP socket ${socketId} error: ${err.message}`) + }) + + socket.on('close', () => { + managed.connected = false + managed.connecting = false + this.sockets.delete(socketId) + debug(`[${pluginId}] TCP socket ${socketId} closed`) + }) + + socket.on('end', () => { + managed.connected = false + debug(`[${pluginId}] TCP socket ${socketId} ended by remote`) + }) + + this.sockets.set(socketId, managed) + debug(`[${pluginId}] Created TCP socket ${socketId}`) + return socketId + } catch (error) { + debug(`Failed to create TCP socket: ${error}`) + return -1 + } + } + + /** + * Connect to a remote host + * @param socketId - Socket to connect + * @param address - Remote host address + * @param port - Remote port + * @returns 0 if connection initiated, -1 on error + */ + connect(socketId: number, address: string, port: number): number { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`TCP socket ${socketId} not found`) + return -1 + } + + if (managed.connected || managed.connecting) { + debug( + `[${managed.pluginId}] TCP socket ${socketId} already connected/connecting` + ) + return -1 + } + + try { + managed.connecting = true + managed.error = null + managed.socket.connect(port, address) + debug( + `[${managed.pluginId}] TCP socket ${socketId} connecting to ${address}:${port}` + ) + return 0 + } catch (error) { + managed.connecting = false + managed.error = String(error) + debug(`[${managed.pluginId}] TCP connect error: ${error}`) + return -1 + } + } + + /** + * Check if socket is connected + * @param socketId - Socket to check + * @returns 1 if connected, 0 if not, -1 if socket not found + */ + isConnected(socketId: number): number { + const managed = this.sockets.get(socketId) + if (!managed) { + return -1 + } + return managed.connected ? 1 : 0 + } + + /** + * Send data over TCP + * @param socketId - Socket to use + * @param data - Data to send + * @returns Bytes sent, or -1 on error + */ + send(socketId: number, data: Buffer): Promise { + return new Promise((resolve) => { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`TCP socket ${socketId} not found`) + resolve(-1) + return + } + + if (!managed.connected) { + debug(`[${managed.pluginId}] TCP socket ${socketId} not connected`) + resolve(-1) + return + } + + managed.socket.write(data, (err) => { + if (err) { + debug(`[${managed.pluginId}] TCP send error: ${err}`) + resolve(-1) + } else { + resolve(data.length) + } + }) + }) + } + + /** + * Receive a complete line (non-blocking) + * @param socketId - Socket to receive from + * @returns Complete line without line ending, or null if no complete line available + */ + receiveLine(socketId: number): string | null { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`TCP socket ${socketId} not found`) + return null + } + + return managed.receiveBuffer.shift() || null + } + + /** + * Receive raw data (non-blocking) + * @param socketId - Socket to receive from + * @returns Raw data buffer, or null if no data available + */ + receiveRaw(socketId: number): Buffer | null { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`TCP socket ${socketId} not found`) + return null + } + + return managed.rawBuffer.shift() || null + } + + /** + * Set buffering mode + * @param socketId - Socket to configure + * @param lineBuffering - true for line-buffered (text), false for raw (binary) + * @returns 0 on success, -1 on error + */ + setLineBuffering(socketId: number, lineBuffering: boolean): number { + const managed = this.sockets.get(socketId) + if (!managed) { + return -1 + } + managed.useLineBuffering = lineBuffering + debug( + `[${managed.pluginId}] TCP socket ${socketId} buffering mode: ${lineBuffering ? 'line' : 'raw'}` + ) + return 0 + } + + /** + * Get number of buffered items (lines or raw chunks) + */ + getBufferedCount(socketId: number): number { + const managed = this.sockets.get(socketId) + if (!managed) return 0 + return managed.useLineBuffering + ? managed.receiveBuffer.length + : managed.rawBuffer.length + } + + /** + * Get last error message + */ + getError(socketId: number): string | null { + const managed = this.sockets.get(socketId) + return managed ? managed.error : null + } + + /** + * Close a TCP socket + * @param socketId - Socket to close + */ + close(socketId: number): void { + const managed = this.sockets.get(socketId) + if (!managed) { + debug(`TCP socket ${socketId} not found`) + return + } + + try { + managed.socket.destroy() + this.sockets.delete(socketId) + debug(`[${managed.pluginId}] TCP socket ${socketId} closed`) + } catch (error) { + debug(`[${managed.pluginId}] TCP close error: ${error}`) + } + } + + /** + * Close all TCP sockets for a plugin + */ + closeAllForPlugin(pluginId: string): void { + const toClose: number[] = [] + for (const [id, managed] of this.sockets) { + if (managed.pluginId === pluginId) { + toClose.push(id) + } + } + for (const id of toClose) { + this.close(id) + } + debug(`[${pluginId}] Closed ${toClose.length} TCP sockets`) + } + + /** + * Get TCP socket statistics + */ + getStats(): { + totalSockets: number + socketsPerPlugin: Record + } { + const socketsPerPlugin: Record = {} + for (const managed of this.sockets.values()) { + socketsPerPlugin[managed.pluginId] = + (socketsPerPlugin[managed.pluginId] || 0) + 1 + } + return { + totalSockets: this.sockets.size, + socketsPerPlugin + } + } +} + +// Export TCP socket manager singleton +export const tcpSocketManager = new TcpSocketManager() + +// Export types +export type { BufferedDatagram, ManagedSocket, ManagedTcpSocket } diff --git a/src/wasm/bindings/weather-provider.ts b/src/wasm/bindings/weather-provider.ts new file mode 100644 index 000000000..4f47c2557 --- /dev/null +++ b/src/wasm/bindings/weather-provider.ts @@ -0,0 +1,341 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Weather Provider Support + * + * Handles weather provider registration and handler invocation for WASM plugins. + * Integrates with Signal K's Weather API at /signalk/v2/api/weather + */ + +import Debug from 'debug' +import { WasmWeatherProvider, WasmPluginInstance } from '../types' + +const debug = Debug('signalk:wasm:weather-provider') + +/** + * Registered weather providers from WASM plugins + * Key: pluginId + */ +export const wasmWeatherProviders: Map = new Map() + +/** + * Call a WASM weather handler function + * Handles both AssemblyScript and Rust plugins with Asyncify support for async operations + */ +export async function callWasmWeatherHandler( + pluginInstance: WasmPluginInstance, + handlerName: string, + requestJson: string +): Promise { + try { + const asLoader = pluginInstance.asLoader + const rawExports = pluginInstance.instance?.exports as any + + if (asLoader && typeof asLoader.exports[handlerName] === 'function') { + // AssemblyScript: allocate string in WASM memory, pass pointer, get string pointer back + // Need to handle Asyncify for handlers that call fetchSync + const requestPtr = asLoader.exports.__newString(requestJson) + + // Set up Asyncify resume handling + let resumePromiseResolve: ((result: string | null) => void) | null = null + const resumePromise = new Promise((resolve) => { + resumePromiseResolve = resolve + }) + + // Store the result pointer from the handler call + let handlerResultPtr: any = null + + if (pluginInstance.setAsyncifyResume) { + pluginInstance.setAsyncifyResume(() => { + debug(`Re-calling ${handlerName} to resume from rewind state`) + const resumeResultPtr = asLoader.exports[handlerName](requestPtr) + const result = asLoader.exports.__getString(resumeResultPtr) + if (resumePromiseResolve) { + resumePromiseResolve(result) + } + return resumeResultPtr + }) + } + + // Call the handler + handlerResultPtr = asLoader.exports[handlerName](requestPtr) + + // Check if we're in Asyncify unwind state + if (typeof asLoader.exports.asyncify_get_state === 'function') { + const state = asLoader.exports.asyncify_get_state() + debug(`Asyncify state after ${handlerName}: ${state}`) + + if (state === 1) { + // State 1 = unwound, waiting for async operation + debug( + `${handlerName} is in unwound state - waiting for async operation to complete` + ) + const result = await resumePromise + debug(`${handlerName} async operation completed`) + if (pluginInstance.setAsyncifyResume) { + pluginInstance.setAsyncifyResume(null) + } + return result + } else { + // Not in async state, clean up + if (pluginInstance.setAsyncifyResume) { + pluginInstance.setAsyncifyResume(null) + } + } + } + + // Normal synchronous return + return asLoader.exports.__getString(handlerResultPtr) + } else if (rawExports && typeof rawExports[handlerName] === 'function') { + // Rust: buffer-based string passing + if (typeof rawExports.allocate !== 'function') { + debug(`Plugin ${pluginInstance.pluginId} missing allocate export`) + return null + } + + const requestBytes = Buffer.from(requestJson, 'utf8') + const requestPtr = rawExports.allocate(requestBytes.length) + const responseMaxLen = 65536 // 64KB response buffer + const responsePtr = rawExports.allocate(responseMaxLen) + + // Write request to WASM memory + const memory = rawExports.memory as WebAssembly.Memory + const memView = new Uint8Array(memory.buffer) + memView.set(requestBytes, requestPtr) + + // Call handler: (request_ptr, request_len, response_ptr, response_max_len) -> written_len + const writtenLen = rawExports[handlerName]( + requestPtr, + requestBytes.length, + responsePtr, + responseMaxLen + ) + + // Read response from WASM memory + const responseBytes = new Uint8Array( + memory.buffer, + responsePtr, + writtenLen + ) + const responseJson = new TextDecoder('utf-8').decode(responseBytes) + + // Deallocate buffers + if (typeof rawExports.deallocate === 'function') { + rawExports.deallocate(requestPtr, requestBytes.length) + rawExports.deallocate(responsePtr, responseMaxLen) + } + + return responseJson + } + + debug( + `Handler ${handlerName} not found in plugin ${pluginInstance.pluginId}` + ) + return null + } catch (error) { + debug(`Error calling weather handler ${handlerName}: ${error}`) + return null + } +} + +/** + * Update weather provider references with a newly loaded plugin instance + */ +export function updateWeatherProviderInstance( + pluginId: string, + pluginInstance: WasmPluginInstance +): void { + const provider = wasmWeatherProviders.get(pluginId) + if (provider) { + provider.pluginInstance = pluginInstance + debug(`Updated weather provider ${pluginId} with plugin instance`) + } +} + +/** + * Clean up weather provider registrations for a plugin + * @param pluginId The plugin ID + * @param app The Signal K app (optional, if provided will also unregister from WeatherApi) + */ +export function cleanupWeatherProviders(pluginId: string, app?: any): void { + if (wasmWeatherProviders.has(pluginId)) { + debug(`Removing weather provider registration: ${pluginId}`) + wasmWeatherProviders.delete(pluginId) + } + + // Also unregister from Signal K WeatherApi + if ( + app && + app.weatherApi && + typeof app.weatherApi.unRegister === 'function' + ) { + try { + app.weatherApi.unRegister(pluginId) + debug(`Unregistered ${pluginId} from WeatherApi`) + } catch (error) { + debug(`Error unregistering from WeatherApi: ${error}`) + } + } +} + +/** + * Create the sk_register_weather_provider host binding + * + * WASM plugins call this to register as a weather provider. + * The plugin must export handler functions: + * - weather_get_observations(requestJson) -> responseJson + * - weather_get_forecasts(requestJson) -> responseJson + * - weather_get_warnings(requestJson) -> responseJson + */ +export function createWeatherProviderBinding( + pluginId: string, + capabilities: { weatherProvider?: boolean }, + app: any, + readUtf8String: (ptr: number, len: number) => string +): (namePtr: number, nameLen: number) => number { + return (namePtr: number, nameLen: number): number => { + try { + const providerName = readUtf8String(namePtr, nameLen) + debug(`[${pluginId}] Registering as weather provider: ${providerName}`) + + // Check if plugin has weatherProvider capability + if (!capabilities.weatherProvider) { + debug(`[${pluginId}] weatherProvider capability not granted`) + return 0 // Failure + } + + // Check if app and weatherApi are available + if (!app || !app.weatherApi) { + debug(`[${pluginId}] app.weatherApi not available`) + return 0 + } + + // Store the registration (we'll update the pluginInstance reference after instance creation) + wasmWeatherProviders.set(pluginId, { + pluginId, + providerName, + pluginInstance: null // Will be set after full instance creation + }) + + // Create WeatherProvider object that calls into WASM handlers + const weatherProvider = { + name: providerName, + methods: { + pluginId: pluginId, + + /** + * Get weather observations for a position + * @param position {latitude, longitude} + * @param options {maxCount?, startDate?, custom?} + */ + getObservations: async ( + position: { latitude: number; longitude: number }, + options?: any + ): Promise => { + const provider = wasmWeatherProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Weather provider instance not ready`) + return [] + } + + const requestJson = JSON.stringify({ position, options }) + const result = await callWasmWeatherHandler( + provider.pluginInstance, + 'weather_get_observations', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) + } catch (e) { + debug( + `[${pluginId}] Failed to parse observations response: ${e}` + ) + return [] + } + } + return [] + }, + + /** + * Get weather forecasts for a position + * @param position {latitude, longitude} + * @param type 'daily' | 'point' + * @param options {maxCount?, startDate?, custom?} + */ + getForecasts: async ( + position: { latitude: number; longitude: number }, + type: 'daily' | 'point', + options?: any + ): Promise => { + const provider = wasmWeatherProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Weather provider instance not ready`) + return [] + } + + const requestJson = JSON.stringify({ position, type, options }) + const result = await callWasmWeatherHandler( + provider.pluginInstance, + 'weather_get_forecasts', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) + } catch (e) { + debug(`[${pluginId}] Failed to parse forecasts response: ${e}`) + return [] + } + } + return [] + }, + + /** + * Get weather warnings for a position + * @param position {latitude, longitude} + */ + getWarnings: async (position: { + latitude: number + longitude: number + }): Promise => { + const provider = wasmWeatherProviders.get(pluginId) + if (!provider || !provider.pluginInstance) { + debug(`[${pluginId}] Weather provider instance not ready`) + return [] + } + + const requestJson = JSON.stringify({ position }) + const result = await callWasmWeatherHandler( + provider.pluginInstance, + 'weather_get_warnings', + requestJson + ) + + if (result) { + try { + return JSON.parse(result) + } catch (e) { + debug(`[${pluginId}] Failed to parse warnings response: ${e}`) + return [] + } + } + return [] + } + } + } + + // Register with Signal K WeatherApi + app.weatherApi.register(pluginId, weatherProvider) + + debug( + `[${pluginId}] Successfully registered as weather provider: ${providerName}` + ) + return 1 // Success + } catch (error) { + debug(`Plugin register weather provider error: ${error}`) + return 0 + } + } +} diff --git a/src/wasm/index.ts b/src/wasm/index.ts new file mode 100644 index 000000000..402d8835c --- /dev/null +++ b/src/wasm/index.ts @@ -0,0 +1,81 @@ +/** + * Signal K WASM Plugin System + * + * Main entry point for WASM/WASIX plugin infrastructure. + * Exports all public APIs for WASM plugin management. + */ + +import { WasmRuntime, initializeWasmRuntime } from './wasm-runtime' +import { + WasmSubscriptionManager, + initializeSubscriptionManager +} from './wasm-subscriptions' + +/** + * Initialize the WASM subsystem + * Returns both runtime and subscription manager for assignment to app + */ +export function initializeWasm(): { + wasmRuntime: WasmRuntime + wasmSubscriptionManager: WasmSubscriptionManager +} { + return { + wasmRuntime: initializeWasmRuntime(), + wasmSubscriptionManager: initializeSubscriptionManager() + } +} + +// Runtime +export { + WasmRuntime, + WasmPluginInstance, + WasmCapabilities, + getWasmRuntime, + initializeWasmRuntime +} from './wasm-runtime' + +// Storage +export { + PluginStoragePaths, + getPluginStoragePaths, + initializePluginVfs, + readPluginConfig, + writePluginConfig, + migrateFromNodeJs, + cleanupVfsTmp, + getVfsDiskUsage, + deletePluginVfs +} from './wasm-storage' + +// Loader +export { + WasmPluginMetadata, + WasmPlugin, + registerWasmPlugin, + startWasmPlugin, + stopWasmPlugin, + unloadWasmPlugin, + reloadWasmPlugin, + handleWasmPluginCrash, + updateWasmPluginConfig, + setWasmPluginEnabled, + getAllWasmPlugins, + getWasmPlugin, + shutdownAllWasmPlugins +} from './loader' + +// ServerAPI Bridge +export { + ServerAPIBridge, + createServerAPIBridge, + createWasmImports, + callWasmExport +} from './wasm-serverapi' + +// Subscriptions +export { + DeltaSubscription, + Delta, + getSubscriptionManager, + initializeSubscriptionManager +} from './wasm-subscriptions' diff --git a/src/wasm/loader/index.ts b/src/wasm/loader/index.ts new file mode 100644 index 000000000..4edd101b6 --- /dev/null +++ b/src/wasm/loader/index.ts @@ -0,0 +1,61 @@ +/** + * WASM Plugin Loader - Main Entry Point + * + * Central export module for the WASM plugin loader subsystem. + * This is the single entry point that re-exports all public APIs from the loader modules. + */ + +// Import lifecycle functions first +import { + startWasmPlugin, + stopWasmPlugin, + unloadWasmPlugin, + reloadWasmPlugin, + handleWasmPluginCrash, + shutdownAllWasmPlugins +} from './plugin-lifecycle' + +import { updateWasmPluginConfig, setWasmPluginEnabled } from './plugin-config' + +// Initialize circular dependency resolution +import { initializeLifecycleFunctions } from './plugin-registry' +initializeLifecycleFunctions( + startWasmPlugin, + updateWasmPluginConfig, + unloadWasmPlugin, + stopWasmPlugin +) + +// Export types +export * from './types' + +// Export registry functions and maps +export { + wasmPlugins, + restartTimers, + setPluginStatus, + registerWasmPlugin, + getAllWasmPlugins, + getWasmPlugin +} from './plugin-registry' + +// Export lifecycle functions +export { + startWasmPlugin, + stopWasmPlugin, + unloadWasmPlugin, + reloadWasmPlugin, + handleWasmPluginCrash, + shutdownAllWasmPlugins +} + +// Export configuration functions +export { updateWasmPluginConfig, setWasmPluginEnabled } + +// Export route setup functions +export { + backwardsCompat, + handleLogViewerRequest, + setupPluginSpecificRoutes, + setupWasmPluginRoutes +} from './plugin-routes' diff --git a/src/wasm/loader/plugin-config.ts b/src/wasm/loader/plugin-config.ts new file mode 100644 index 000000000..20b56a1ae --- /dev/null +++ b/src/wasm/loader/plugin-config.ts @@ -0,0 +1,140 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Plugin Configuration Management + * + * Handles plugin configuration updates and enabled state changes. + * Persists configuration to disk and manages plugin state transitions. + */ + +import Debug from 'debug' +import { wasmPlugins } from './plugin-registry' +import { startWasmPlugin, stopWasmPlugin } from './plugin-lifecycle' +import { getPluginStoragePaths, writePluginConfig } from '../wasm-storage' + +const debug = Debug('signalk:wasm:loader') + +/** + * Update WASM plugin configuration + */ +export async function updateWasmPluginConfig( + app: any, + pluginId: string, + configuration: any, + configPath: string +): Promise { + const plugin = wasmPlugins.get(pluginId) + if (!plugin) { + throw new Error(`WASM plugin ${pluginId} not found`) + } + + debug(`updateWasmPluginConfig: Starting for ${pluginId}`) + debug( + `updateWasmPluginConfig: New configuration: ${JSON.stringify(configuration)}` + ) + + plugin.configuration = configuration + debug(`updateWasmPluginConfig: Updated in-memory configuration`) + + // Save to disk + const storagePaths = getPluginStoragePaths( + configPath, + plugin.id, + plugin.packageName + ) + debug(`updateWasmPluginConfig: Config file path: ${storagePaths.configFile}`) + + const config = { + configuration: configuration ?? {}, // Ensure configuration is always an object, never undefined + enabled: plugin.enabled, + enableDebug: plugin.enableDebug + } + debug( + `updateWasmPluginConfig: Writing config to disk: ${JSON.stringify(config)}` + ) + writePluginConfig(storagePaths.configFile, config) + debug(`updateWasmPluginConfig: Config written to disk`) + + // Restart plugin if running AND still enabled + // Don't restart if the plugin is being disabled + if (plugin.status === 'running' && plugin.enabled) { + debug( + `updateWasmPluginConfig: Plugin is running and enabled, restarting...` + ) + await stopWasmPlugin(pluginId) + debug(`updateWasmPluginConfig: Plugin stopped`) + await startWasmPlugin(app, pluginId) + debug(`updateWasmPluginConfig: Plugin started`) + plugin.statusMessage = 'Configuration updated' + } else { + debug( + `updateWasmPluginConfig: Plugin not running (status: ${plugin.status}) or not enabled (enabled: ${plugin.enabled}), skipping restart` + ) + } + + debug(`updateWasmPluginConfig: Configuration updated for ${pluginId}`) +} + +/** + * Enable/disable a WASM plugin + */ +export async function setWasmPluginEnabled( + app: any, + pluginId: string, + enabled: boolean, + configPath: string +): Promise { + const plugin = wasmPlugins.get(pluginId) + if (!plugin) { + throw new Error(`WASM plugin ${pluginId} not found`) + } + + debug(`setWasmPluginEnabled: Starting for ${pluginId}, enabled=${enabled}`) + debug( + `setWasmPluginEnabled: Current state - enabled: ${plugin.enabled}, status: ${plugin.status}` + ) + + plugin.enabled = enabled + debug(`setWasmPluginEnabled: Updated in-memory enabled flag to ${enabled}`) + + // Save to disk + const storagePaths = getPluginStoragePaths( + configPath, + plugin.id, + plugin.packageName + ) + debug(`setWasmPluginEnabled: Config file path: ${storagePaths.configFile}`) + + const config = { + configuration: plugin.configuration ?? {}, // Ensure configuration is always an object, never undefined + enabled, + enableDebug: plugin.enableDebug + } + debug( + `setWasmPluginEnabled: Writing config to disk: ${JSON.stringify(config)}` + ) + writePluginConfig(storagePaths.configFile, config) + debug(`setWasmPluginEnabled: Config written to disk`) + + // Start or stop accordingly + if (enabled && plugin.status !== 'running') { + debug( + `setWasmPluginEnabled: Plugin should be enabled and is not running, starting...` + ) + await startWasmPlugin(app, pluginId) + debug(`setWasmPluginEnabled: Plugin started, new status: ${plugin.status}`) + } else if (!enabled && plugin.status === 'running') { + debug( + `setWasmPluginEnabled: Plugin should be disabled and is running, stopping...` + ) + await stopWasmPlugin(pluginId) + debug(`setWasmPluginEnabled: Plugin stopped, new status: ${plugin.status}`) + } else { + debug( + `setWasmPluginEnabled: No action needed - enabled=${enabled}, status=${plugin.status}` + ) + } + + debug( + `setWasmPluginEnabled: Completed - Plugin ${pluginId} ${enabled ? 'enabled' : 'disabled'}` + ) +} diff --git a/src/wasm/loader/plugin-lifecycle.ts b/src/wasm/loader/plugin-lifecycle.ts new file mode 100644 index 000000000..c7eee6f9e --- /dev/null +++ b/src/wasm/loader/plugin-lifecycle.ts @@ -0,0 +1,472 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * WASM Plugin Lifecycle Operations + * + * Manages plugin lifecycle operations including start, stop, reload, unload, + * crash handling, and shutdown. Handles state transitions and cleanup. + */ + +import Debug from 'debug' +import { WasmPlugin } from './types' +import { wasmPlugins, restartTimers, setPluginStatus } from './plugin-registry' +import { getWasmRuntime, resetWasmRuntime } from '../wasm-runtime' +import { resetSubscriptionManager } from '../wasm-subscriptions' +import { backwardsCompat } from './plugin-routes' +import { updateResourceProviderInstance } from '../bindings/resource-provider' +import { updateWeatherProviderInstance } from '../bindings/weather-provider' +import { updateRadarProviderInstance } from '../bindings/radar-provider' +import { socketManager } from '../bindings/socket-manager' + +const debug = Debug('signalk:wasm:loader') + +// Track poll timers for plugins that request periodic polling +const pollTimers: Map = new Map() + +// Track delta subscription unsubscribe functions for plugins +const deltaUnsubscribers: Map void> = new Map() + +// Mutex for serializing network-capable plugin starts +// as-fetch uses global state that gets corrupted with parallel plugin starts +let networkPluginStartMutex: Promise = Promise.resolve() + +/** + * Start a WASM plugin + */ +export async function startWasmPlugin( + app: any, + pluginId: string +): Promise { + const plugin = wasmPlugins.get(pluginId) + if (!plugin) { + throw new Error(`WASM plugin ${pluginId} not found`) + } + + if (plugin.status === 'running') { + debug(`Plugin ${pluginId} already running`) + return + } + + // Serialize starts for network-capable plugins to avoid as-fetch global state corruption + if (plugin.metadata?.capabilities?.network) { + debug(`Plugin ${pluginId} has network capability, waiting for mutex...`) + const previousMutex = networkPluginStartMutex + let releaseMutex: () => void + networkPluginStartMutex = new Promise((resolve) => { + releaseMutex = resolve + }) + await previousMutex + debug(`Plugin ${pluginId} acquired start mutex`) + try { + await startWasmPluginInternal(app, plugin, pluginId) + } finally { + debug(`Plugin ${pluginId} releasing start mutex`) + releaseMutex!() + } + } else { + await startWasmPluginInternal(app, plugin, pluginId) + } +} + +async function startWasmPluginInternal( + app: any, + plugin: WasmPlugin, + pluginId: string +): Promise { + debug(`Starting WASM plugin: ${pluginId}`) + setPluginStatus(plugin, 'starting') + plugin.errorMessage = undefined + + try { + if (!plugin.instance) { + throw new Error('Plugin instance not loaded') + } + + // Call plugin start() with configuration + // Pass the entire configuration object including enableDebug at root level + const startConfig = { + ...plugin.configuration, + enableDebug: plugin.enableDebug + } + const configJson = JSON.stringify(startConfig) + debug(`Starting plugin with config: ${configJson}`) + const result = await plugin.instance.exports.start(configJson) + + if (result !== 0) { + throw new Error(`Plugin start() returned error code: ${result}`) + } + + // Update provider instance references after plugin_start() completes + // Providers are registered during start(), so we need to update + // references using BOTH the packageName (used in env bindings) and real pluginId + if (plugin.packageName) { + updateResourceProviderInstance(plugin.packageName, plugin.instance) + updateWeatherProviderInstance(plugin.packageName, plugin.instance) + updateRadarProviderInstance(plugin.packageName, plugin.instance) + } + updateResourceProviderInstance(pluginId, plugin.instance) + updateWeatherProviderInstance(pluginId, plugin.instance) + updateRadarProviderInstance(pluginId, plugin.instance) + + setPluginStatus(plugin, 'running') + plugin.statusMessage = 'Running' + plugin.crashCount = 0 // Reset crash count on successful start + plugin.restartBackoff = 1000 + + // Set up periodic polling for plugins that export poll() + // This is a generic mechanism for plugins that need to poll hardware, + // sockets, or external systems (e.g., radar, NMEA receivers, sensors) + if (plugin.instance?.exports?.poll) { + const pollInterval = 1000 // Poll every 1 second + debug(`Setting up poll timer for ${pluginId} (${pollInterval}ms)`) + + const pollTimer = setInterval(() => { + try { + if (plugin.status === 'running' && plugin.instance?.exports?.poll) { + const result = plugin.instance.exports.poll() + if (result !== 0) { + debug(`[${pluginId}] poll() returned: ${result}`) + } + } + } catch (pollError) { + debug(`[${pluginId}] poll() error: ${pollError}`) + } + }, pollInterval) + + pollTimers.set(pluginId, pollTimer) + } + + // Set up delta subscription if plugin exports delta_handler + if (plugin.instance?.exports?.delta_handler) { + debug(`Setting up delta subscription for ${pluginId}`) + + // Subscribe to deltas from the server + if (app.signalk && typeof app.signalk.on === 'function') { + const deltaHandler = (delta: any) => { + try { + if ( + plugin.status === 'running' && + plugin.instance?.exports?.delta_handler + ) { + const deltaJson = JSON.stringify(delta) + plugin.instance.exports.delta_handler(deltaJson) + } + } catch (deltaError) { + debug(`[${pluginId}] delta_handler error: ${deltaError}`) + } + } + + // Subscribe to delta events + app.signalk.on('delta', deltaHandler) + + // Store unsubscribe function + deltaUnsubscribers.set(pluginId, () => { + if (app.signalk && typeof app.signalk.removeListener === 'function') { + app.signalk.removeListener('delta', deltaHandler) + } + }) + + debug(`Delta subscription active for ${pluginId}`) + } else { + debug(`Warning: app.signalk not available for delta subscription`) + } + } + + debug(`Successfully started WASM plugin: ${pluginId}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + setPluginStatus(plugin, 'error') + plugin.errorMessage = errorMsg + debug(`Failed to start WASM plugin ${pluginId}: ${errorMsg}`) + throw error + } +} + +/** + * Stop a WASM plugin + */ +export async function stopWasmPlugin(pluginId: string): Promise { + const plugin = wasmPlugins.get(pluginId) + if (!plugin) { + throw new Error(`WASM plugin ${pluginId} not found`) + } + + debug(`Stopping WASM plugin: ${pluginId}`) + + try { + // Cancel any pending restart timers + const timer = restartTimers.get(pluginId) + if (timer) { + clearTimeout(timer) + restartTimers.delete(pluginId) + } + + // Cancel any poll timers + const pollTimer = pollTimers.get(pluginId) + if (pollTimer) { + clearInterval(pollTimer) + pollTimers.delete(pluginId) + debug(`Stopped poll timer for ${pluginId}`) + } + + // Unsubscribe from delta events + const deltaUnsubscriber = deltaUnsubscribers.get(pluginId) + if (deltaUnsubscriber) { + deltaUnsubscriber() + deltaUnsubscribers.delete(pluginId) + debug(`Stopped delta subscription for ${pluginId}`) + } + + if (plugin.instance) { + // Call plugin stop() + const result = plugin.instance.exports.stop() + if (result !== 0) { + debug(`Plugin stop() returned error code: ${result}`) + } + } + + // Clean up any sockets opened by this plugin + socketManager.closeAllForPlugin(pluginId) + + setPluginStatus(plugin, 'stopped') + plugin.statusMessage = 'Stopped' + debug(`Successfully stopped WASM plugin: ${pluginId}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Error stopping WASM plugin ${pluginId}: ${errorMsg}`) + setPluginStatus(plugin, 'error') + plugin.errorMessage = errorMsg + throw error + } +} + +/** + * Unload a WASM plugin completely (remove from memory and unregister routes) + */ +export async function unloadWasmPlugin( + app: any, + pluginId: string +): Promise { + const plugin = wasmPlugins.get(pluginId) + if (!plugin) { + throw new Error(`WASM plugin ${pluginId} not found`) + } + + debug(`Unloading WASM plugin: ${pluginId}`) + + try { + // Stop the plugin first if running + if (plugin.status === 'running') { + await stopWasmPlugin(pluginId) + } + + // Remove HTTP routes from Express + if (plugin.router) { + debug(`Removing HTTP routes for ${pluginId}`) + // Express doesn't have a built-in way to remove routes, so we need to + // remove the middleware from the app stack + const paths = backwardsCompat(`/plugins/${pluginId}`) + + // Remove all route handlers for this plugin + paths.forEach((path) => { + if (app._router && app._router.stack) { + app._router.stack = app._router.stack.filter((layer: any) => { + // Remove layers that match this plugin's path + if (layer.route) { + const routePath = layer.route.path + // Handle both string and array cases for route.path + if (typeof routePath === 'string') { + return !routePath.startsWith(path) + } else if (Array.isArray(routePath)) { + return !routePath.some((p) => p.startsWith(path)) + } + return true + } + if (layer.name === 'router' && layer.regexp) { + return !layer.regexp.test(path) + } + return true + }) + } + }) + + plugin.router = undefined + debug(`Removed HTTP routes for ${pluginId}`) + } + + // Destroy WASM instance and free memory + if (plugin.instance) { + debug(`Destroying WASM instance for ${pluginId}`) + + // Clear any references to help garbage collection + plugin.instance = undefined + + debug(`Destroyed WASM instance for ${pluginId}`) + } + + setPluginStatus(plugin, 'stopped') + plugin.statusMessage = 'Unloaded' + debug(`Successfully unloaded WASM plugin: ${pluginId}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Error unloading WASM plugin ${pluginId}: ${errorMsg}`) + setPluginStatus(plugin, 'error') + plugin.errorMessage = errorMsg + throw error + } +} + +/** + * Reload a WASM plugin (hot-reload without server restart) + */ +export async function reloadWasmPlugin( + app: any, + pluginId: string +): Promise { + const plugin = wasmPlugins.get(pluginId) + if (!plugin) { + throw new Error(`WASM plugin ${pluginId} not found`) + } + + debug(`Reloading WASM plugin: ${pluginId}`) + + try { + const wasRunning = plugin.status === 'running' + + // Stop the plugin + if (wasRunning) { + await stopWasmPlugin(pluginId) + } + + // Save current configuration + const savedConfig = plugin.configuration + + // Reload WASM module + const runtime = getWasmRuntime() + await runtime.reloadPlugin(pluginId) + + // Get new instance + const newInstance = runtime.getInstance(pluginId) + if (!newInstance) { + throw new Error('Failed to get reloaded instance') + } + + plugin.instance = newInstance + + // Update schema from new instance + const schemaJson = newInstance.exports.schema() + plugin.schema = schemaJson ? JSON.parse(schemaJson) : {} + + // Restart if it was running + if (wasRunning) { + await startWasmPlugin(app, pluginId) + } + + plugin.statusMessage = 'Reloaded successfully' + debug(`Successfully reloaded WASM plugin: ${pluginId}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + setPluginStatus(plugin, 'error') + plugin.errorMessage = `Reload failed: ${errorMsg}` + debug(`Failed to reload WASM plugin ${pluginId}: ${errorMsg}`) + throw error + } +} + +/** + * Handle WASM plugin crash with automatic restart + */ +export async function handleWasmPluginCrash( + app: any, + pluginId: string, + error: Error +): Promise { + const plugin = wasmPlugins.get(pluginId) + if (!plugin) { + return + } + + plugin.crashCount++ + plugin.lastCrash = new Date() + setPluginStatus(plugin, 'crashed') + plugin.errorMessage = `Crashed: ${error.message}` + + debug( + `WASM plugin ${pluginId} crashed (count: ${plugin.crashCount}): ${error.message}` + ) + + // Give up after 3 crashes in quick succession + if (plugin.crashCount >= 3) { + setPluginStatus(plugin, 'error') + plugin.errorMessage = + 'Plugin repeatedly crashing, automatic restart disabled' + debug(`Plugin ${pluginId} disabled after 3 crashes`) + return + } + + // Schedule restart with exponential backoff + plugin.restartBackoff = Math.min(plugin.restartBackoff * 2, 30000) // Max 30 seconds + + debug(`Scheduling restart for ${pluginId} in ${plugin.restartBackoff}ms`) + + const timer = setTimeout(async () => { + try { + debug(`Attempting automatic restart of ${pluginId}`) + await reloadWasmPlugin(app, pluginId) + plugin.statusMessage = 'Recovered from crash' + } catch (restartError) { + debug(`Failed to restart ${pluginId}:`, restartError) + setPluginStatus(plugin, 'error') + plugin.errorMessage = 'Failed to recover from crash' + } + }, plugin.restartBackoff) + + restartTimers.set(pluginId, timer) +} + +/** + * Shutdown all WASM plugins + */ +export async function shutdownAllWasmPlugins(): Promise { + debug('Shutting down all WASM plugins') + debug(`Number of plugins in registry: ${wasmPlugins.size}`) + + // Clear all restart timers + for (const timer of restartTimers.values()) { + clearTimeout(timer) + } + restartTimers.clear() + + // Stop all plugins + const plugins = Array.from(wasmPlugins.values()) + debug( + `Plugins to shutdown: ${plugins.map((p) => `${p.id}(${p.status})`).join(', ')}` + ) + for (const plugin of plugins) { + try { + if (plugin.status === 'running') { + debug(`Stopping plugin ${plugin.id}...`) + await stopWasmPlugin(plugin.id) + debug(`Plugin ${plugin.id} stopped, status now: ${plugin.status}`) + } else { + debug( + `Plugin ${plugin.id} not running (status=${plugin.status}), skipping stop` + ) + } + } catch (error) { + debug(`Error stopping plugin ${plugin.id}:`, error) + } + } + + // Shutdown runtime + const runtime = getWasmRuntime() + await runtime.shutdown() + + // Reset singletons + resetWasmRuntime() + resetSubscriptionManager() + + wasmPlugins.clear() + debug('All WASM plugins shut down') +} diff --git a/src/wasm/loader/plugin-registry.ts b/src/wasm/loader/plugin-registry.ts new file mode 100644 index 000000000..4d2975c99 --- /dev/null +++ b/src/wasm/loader/plugin-registry.ts @@ -0,0 +1,423 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * WASM Plugin Registration and Management + * + * Manages the global plugin registry and handles plugin registration. + * Maintains the plugin map and provides lookup functions. + */ + +import * as path from 'path' +import * as fs from 'fs' +import Debug from 'debug' +import express from 'express' +import { WasmPlugin } from './types' +import { getWasmRuntime, WasmCapabilities } from '../wasm-runtime' +import { + getPluginStoragePaths, + initializePluginVfs, + readPluginConfig, + writePluginConfig +} from '../wasm-storage' +import { setupWasmPluginRoutes } from './plugin-routes' +import { updateResourceProviderInstance } from '../bindings/resource-provider' +import { updateWeatherProviderInstance } from '../bindings/weather-provider' +import { updateRadarProviderInstance } from '../bindings/radar-provider' +import { derivePluginId } from '../../pluginid' + +const debug = Debug('signalk:wasm:loader') + +// Global plugin registry +export const wasmPlugins: Map = new Map() + +// Crash recovery timers +export const restartTimers: Map = new Map() + +// Forward declarations for circular dependency resolution +let _startWasmPlugin: (app: any, pluginId: string) => Promise +let _updateWasmPluginConfig: ( + app: any, + pluginId: string, + configuration: any, + configPath: string +) => Promise +let _unloadWasmPlugin: (app: any, pluginId: string) => Promise +let _stopWasmPlugin: (pluginId: string) => Promise + +/** + * Initialize lifecycle function references (called from index.ts to resolve circular dependencies) + */ +export function initializeLifecycleFunctions( + startWasmPlugin: (app: any, pluginId: string) => Promise, + updateWasmPluginConfig: ( + app: any, + pluginId: string, + configuration: any, + configPath: string + ) => Promise, + unloadWasmPlugin: (app: any, pluginId: string) => Promise, + stopWasmPlugin: (pluginId: string) => Promise +) { + _startWasmPlugin = startWasmPlugin + _updateWasmPluginConfig = updateWasmPluginConfig + _unloadWasmPlugin = unloadWasmPlugin + _stopWasmPlugin = stopWasmPlugin +} + +/** + * Helper to update plugin status and sync state property + */ +export function setPluginStatus( + plugin: WasmPlugin, + status: WasmPlugin['status'] +) { + plugin.status = status + plugin.state = status +} + +/** + * Add Node.js plugin compatibility properties to WASM plugin + * This allows WASM plugins to be used interchangeably with Node.js plugins + */ +function addNodejsPluginCompat(plugin: WasmPlugin, pluginId: string): void { + // Add 'started' getter for Node.js plugin compatibility + Object.defineProperty(plugin, 'started', { + get() { + return this.status === 'running' + }, + enumerable: true, + configurable: true + }) + + // Add 'stop' method for Node.js plugin compatibility + ;(plugin as any).stop = async function () { + if (_stopWasmPlugin) { + await _stopWasmPlugin(pluginId) + } + } +} + +/** + * Mount webapp static files and register with app.webapps for WASM plugins + * that have the signalk-webapp keyword + */ +function mountWasmWebapp( + app: any, + packageName: string, + packageJson: any, + location: string +): void { + const keywords = packageJson.keywords || [] + + // Check if this is a webapp + if (!keywords.includes('signalk-webapp')) { + return + } + + // Find public folder + const packagePath = path.join(location, packageName) + let webappPath = packagePath + if (fs.existsSync(path.join(packagePath, 'public'))) { + webappPath = path.join(packagePath, 'public') + } + + // Mount static files + debug(`Mounting WASM webapp /${packageName}: ${webappPath}`) + app.use('/' + packageName, express.static(webappPath)) + + // Create webapp metadata for admin UI + const webappMetadata = { + name: packageName, + version: packageJson.version, + description: packageJson.description || '', + keywords: keywords, + signalk: packageJson.signalk || {} + } + + // Register with app.webapps + if (!app.webapps) { + app.webapps = [] + } + // Avoid duplicates + if (!app.webapps.find((w: any) => w.name === packageName)) { + app.webapps.push(webappMetadata) + debug(`Registered WASM webapp: ${packageName}`) + } + + // Also register as embeddable webapp if it has that keyword + if (keywords.includes('signalk-embeddable-webapp')) { + if (!app.embeddablewebapps) { + app.embeddablewebapps = [] + } + if (!app.embeddablewebapps.find((w: any) => w.name === packageName)) { + app.embeddablewebapps.push(webappMetadata) + debug(`Registered WASM embeddable webapp: ${packageName}`) + } + } +} + +/** + * Register a WASM plugin from package metadata + */ +export async function registerWasmPlugin( + app: any, + packageName: string, + metadata: any, + location: string, + configPath: string +): Promise { + debug(`Registering WASM plugin: ${packageName} from ${location}`) + + try { + // Read package.json to get WASM metadata + const packageJson = require( + path.join(location, packageName, 'package.json') + ) + + if (!packageJson.wasmManifest) { + throw new Error('Missing wasmManifest in package.json') + } + + const wasmPath = path.join(location, packageName, packageJson.wasmManifest) + + // Mount webapp static files if this is a signalk-webapp + mountWasmWebapp(app, packageName, packageJson, location) + + const capabilities: WasmCapabilities = { + network: packageJson.wasmCapabilities?.network || false, + storage: packageJson.wasmCapabilities?.storage || 'vfs-only', + dataRead: packageJson.wasmCapabilities?.dataRead !== false, // default true + dataWrite: packageJson.wasmCapabilities?.dataWrite !== false, // default true + serialPorts: packageJson.wasmCapabilities?.serialPorts || false, + putHandlers: packageJson.wasmCapabilities?.putHandlers || false, + httpEndpoints: packageJson.wasmCapabilities?.httpEndpoints || false, + resourceProvider: packageJson.wasmCapabilities?.resourceProvider || false, + weatherProvider: packageJson.wasmCapabilities?.weatherProvider || false, + radarProvider: packageJson.wasmCapabilities?.radarProvider || false, + rawSockets: packageJson.wasmCapabilities?.rawSockets || false + } + + // Load WASM module temporarily to extract schema and display name + const tempVfsRoot = path.join( + configPath, + 'plugin-config-data', + '.temp-' + derivePluginId(packageName) + ) + if (!fs.existsSync(tempVfsRoot)) { + fs.mkdirSync(tempVfsRoot, { recursive: true }) + } + + const runtime = getWasmRuntime() + const tempInstance = await runtime.loadPlugin( + packageName, + wasmPath, + tempVfsRoot, + capabilities, + app + ) + + // Derive plugin ID from npm package name (not from WASM exports) + // This ensures uniqueness via npm registry and prevents ID conflicts + const pluginId = derivePluginId(packageName) + // Plugin display name from WASM exports, fallback to package name + const pluginName = tempInstance.exports.name?.() || packageName + const schemaJson = tempInstance.exports.schema() + const schema = schemaJson ? JSON.parse(schemaJson) : {} + + // Update the plugin instance reference for providers + updateResourceProviderInstance(pluginId, tempInstance) + updateWeatherProviderInstance(pluginId, tempInstance) + updateRadarProviderInstance(pluginId, tempInstance) + + // Now check config using the REAL plugin ID + const storagePaths = getPluginStoragePaths( + configPath, + pluginId, + packageName + ) + const savedConfig = readPluginConfig(storagePaths.configFile) + + // If plugin is disabled, create minimal plugin object and return early + if (savedConfig.enabled === false) { + debug( + `Plugin ${packageName} is disabled, schema already extracted from WASM` + ) + + // Do NOT write config file here - UI shows "Configure" button when no config file exists + // Config file will be created when user actually configures the plugin + + // Create a minimal plugin object without keeping WASM loaded + const plugin: WasmPlugin = { + id: pluginId, + name: pluginName, + type: 'wasm', + packageName, + version: metadata.version || packageJson.version, + enabled: false, + enableDebug: savedConfig.enableDebug || false, + keywords: packageJson.keywords || [], + packageLocation: location, + configPath, + metadata: { + id: pluginId, + name: pluginName, + packageName, + version: metadata.version || packageJson.version, + wasmManifest: packageJson.wasmManifest, + capabilities, + packageLocation: location + }, + instance: null as any, // Instance was destroyed + status: 'stopped', + schema, // Schema extracted from temp load + configuration: savedConfig.configuration, // Keep undefined/null for UI "Configure" button logic + crashCount: 0, + restartBackoff: 1000, + description: packageJson.description || '', + state: 'stopped', + format: tempInstance.format // Preserve format from temp instance + } + + // Add Node.js plugin compatibility properties + addNodejsPluginCompat(plugin, pluginId) + + // Register minimal plugin in global map + wasmPlugins.set(pluginId, plugin) + + // Add to app.plugins array + if (app.plugins) { + app.plugins.push(plugin) + } + + // Add to app.pluginsMap + if (app.pluginsMap) { + app.pluginsMap[pluginId] = plugin + } + + // Set up basic REST API routes even though plugin is disabled + // This allows Plugin Config UI to read/write config and enable the plugin + setupWasmPluginRoutes( + app, + plugin, + configPath, + _updateWasmPluginConfig, + _startWasmPlugin, + _unloadWasmPlugin, + _stopWasmPlugin + ) + + debug( + `Registered disabled WASM plugin: ${pluginId} (${pluginName}) - schema available, instance not loaded` + ) + return plugin + } + + // Plugin is enabled - proceed with full load + debug( + `Plugin ${packageName} is enabled, initializing VFS and preparing for startup` + ) + + // Initialize VFS with the real plugin ID + initializePluginVfs(storagePaths) + + // Clean up temp VFS + if (fs.existsSync(tempVfsRoot)) { + fs.rmSync(tempVfsRoot, { recursive: true, force: true }) + } + + // Write initial config file if it doesn't exist + if (!fs.existsSync(storagePaths.configFile)) { + debug(`Creating initial config file for ${packageName}`) + writePluginConfig(storagePaths.configFile, savedConfig) + } + + // Use the instance we already loaded + const instance = tempInstance + + // Create plugin object + const plugin: WasmPlugin = { + id: pluginId, + name: pluginName, + type: 'wasm', + packageName, + version: metadata.version || packageJson.version, + enabled: savedConfig.enabled || false, + enableDebug: savedConfig.enableDebug || false, + keywords: packageJson.keywords || [], + packageLocation: location, + configPath, + metadata: { + id: pluginId, + name: pluginName, + packageName, + version: metadata.version || packageJson.version, + wasmManifest: packageJson.wasmManifest, + capabilities, + packageLocation: location + }, + instance, + status: 'stopped', + schema, + configuration: savedConfig.configuration, // Keep undefined/null for UI "Configure" button logic + crashCount: 0, + restartBackoff: 1000, // Start with 1 second + description: packageJson.description || '', + state: 'stopped', + format: instance.format // WASM binary format (wasi-p1 or component-model) + } + + // Add Node.js plugin compatibility properties + addNodejsPluginCompat(plugin, pluginId) + + // Register in global map + wasmPlugins.set(pluginId, plugin) + + // Add to app.plugins array for unified plugin management + if (app.plugins) { + app.plugins.push(plugin) + } + + // Add to app.pluginsMap for plugin API compatibility + if (app.pluginsMap) { + app.pluginsMap[pluginId] = plugin + } + + // Set up REST API routes for this plugin + setupWasmPluginRoutes( + app, + plugin, + configPath, + _updateWasmPluginConfig, + _startWasmPlugin, + _unloadWasmPlugin, + _stopWasmPlugin + ) + + debug(`Registered WASM plugin: ${pluginId} (${pluginName})`) + + // Auto-start if enabled + if (plugin.enabled) { + await _startWasmPlugin(app, pluginId) + } + + return plugin + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Failed to register WASM plugin ${packageName}: ${errorMsg}`) + throw new Error(`Failed to register WASM plugin: ${errorMsg}`) + } +} + +/** + * Get all WASM plugins + */ +export function getAllWasmPlugins(): WasmPlugin[] { + return Array.from(wasmPlugins.values()) +} + +/** + * Get a WASM plugin by ID + */ +export function getWasmPlugin(pluginId: string): WasmPlugin | undefined { + return wasmPlugins.get(pluginId) +} diff --git a/src/wasm/loader/plugin-routes.ts b/src/wasm/loader/plugin-routes.ts new file mode 100644 index 000000000..6b0f7d909 --- /dev/null +++ b/src/wasm/loader/plugin-routes.ts @@ -0,0 +1,660 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * WASM Plugin HTTP Route Handlers + * + * Handles HTTP route registration and request handling for WASM plugins. + * Includes custom endpoint routing, log streaming, and basic REST API endpoints. + */ + +import * as path from 'path' +import * as express from 'express' +import { Request, Response } from 'express' +import { spawn } from 'child_process' +import * as readline from 'readline' +import Debug from 'debug' +import { WasmPlugin } from './types' +import { getWasmRuntime } from '../wasm-runtime' +import { + getPluginStoragePaths, + readPluginConfig, + writePluginConfig +} from '../wasm-storage' +import { SERVERROUTESPREFIX } from '../../constants' + +const debug = Debug('signalk:wasm:loader') + +/** + * Helper to support both prefixed and non-prefixed routes + */ +export function backwardsCompat(url: string) { + return [`${SERVERROUTESPREFIX}${url}`, url] +} + +/** + * Handle /api/logs request directly in Node.js (for signalk-logviewer plugin) + * This avoids WASM memory buffer limitations (~64KB) when streaming large logs + */ +export async function handleLogViewerRequest( + req: Request, + res: Response +): Promise { + try { + const lines = parseInt(req.query.lines as string) || 2000 + const maxLines = Math.min(lines, 50000) // Cap at 50000 lines + + debug(`[logviewer] Fetching ${maxLines} log lines via Node.js streaming`) + + // Try journalctl first + const p = spawn('journalctl', [ + '-u', + 'signalk', + '-n', + maxLines.toString(), + '--output=short-iso', + '--no-pager' + ]) + + const logLines: string[] = [] + let hasError = false + let errorOutput = '' + + // Stream lines using readline + const rl = readline.createInterface({ + input: p.stdout, + crlfDelay: Infinity + }) + + rl.on('line', (line) => { + if (line.trim().length > 0) { + logLines.push(line) + } + }) + + p.stderr.on('data', (data) => { + errorOutput += data.toString() + }) + + p.on('error', (err) => { + debug(`[logviewer] journalctl spawn error: ${err.message}`) + hasError = true + }) + + // Wait for process to complete + await new Promise((resolve) => { + p.on('close', (code) => { + debug(`[logviewer] journalctl exited with code ${code}`) + if (code !== 0) { + hasError = true + } + resolve() + }) + }) + + if (hasError || logLines.length === 0) { + debug(`[logviewer] journalctl failed, trying file-based logs`) + + // Fallback to reading from file + try { + const tailP = spawn('tail', [ + '-n', + maxLines.toString(), + '/var/log/syslog' + ]) + logLines.length = 0 // Clear array + + const tailRl = readline.createInterface({ + input: tailP.stdout, + crlfDelay: Infinity + }) + + tailRl.on('line', (line) => { + if (line.trim().length > 0) { + logLines.push(line) + } + }) + + await new Promise((resolve) => { + tailP.on('close', () => resolve()) + }) + } catch (tailErr) { + debug(`[logviewer] tail also failed: ${tailErr}`) + } + } + + if (logLines.length === 0) { + res.status(404).json({ + error: 'Could not find logs', + message: 'Tried journalctl and file-based logs' + }) + return + } + + debug( + `[logviewer] Retrieved ${logLines.length} log lines, sending response` + ) + + // Send response + res.json({ + lines: logLines, + count: logLines.length, + source: 'journalctl', + format: 'short-iso' + }) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`[logviewer] Error handling request: ${errorMsg}`) + res.status(500).json({ error: errorMsg }) + } +} + +/** + * Add plugin-specific HTTP endpoints to an existing router + * This is called when enabling a previously disabled plugin + */ +export function setupPluginSpecificRoutes(plugin: WasmPlugin): void { + if (!plugin.router) { + debug( + `Warning: Cannot setup plugin-specific routes - no router found for ${plugin.id}` + ) + return + } + + if (!plugin.instance) { + debug(`No instance for ${plugin.id}`) + return + } + + // Check for http_endpoints in either AssemblyScript loader or raw WASM exports + const hasAsEndpoints = + plugin.instance.asLoader && + typeof plugin.instance.asLoader.exports.http_endpoints === 'function' + const hasRustEndpoints = + plugin.instance.instance && + typeof (plugin.instance.instance.exports as any).http_endpoints === + 'function' + + if (!hasAsEndpoints && !hasRustEndpoints) { + debug(`No custom HTTP endpoints for ${plugin.id}`) + return + } + + const router = plugin.router + + // Register custom HTTP endpoints + try { + let endpointsJson: string + + // Check if this is an AssemblyScript or Rust plugin + const asLoader = plugin.instance.asLoader + if (asLoader && typeof asLoader.exports.http_endpoints === 'function') { + // AssemblyScript: http_endpoints() returns a string pointer + const ptr = asLoader.exports.http_endpoints() + endpointsJson = asLoader.exports.__getString(ptr) + debug( + `Got http_endpoints from AssemblyScript: ${endpointsJson.substring(0, 200)}` + ) + } else { + // Rust: http_endpoints(out_ptr, out_max_len) -> written_len + const rawExports = plugin.instance.instance.exports as any + if ( + typeof rawExports.allocate === 'function' && + typeof rawExports.http_endpoints === 'function' + ) { + const maxLen = 8192 // 8KB should be plenty for endpoint definitions + const outPtr = rawExports.allocate(maxLen) + const writtenLen = rawExports.http_endpoints(outPtr, maxLen) + + // Read the string from WASM memory + const memory = rawExports.memory as WebAssembly.Memory + const bytes = new Uint8Array(memory.buffer, outPtr, writtenLen) + endpointsJson = new TextDecoder('utf-8').decode(bytes) + + // Deallocate + if (typeof rawExports.deallocate === 'function') { + rawExports.deallocate(outPtr, maxLen) + } + debug( + `Got http_endpoints from Rust (${writtenLen} bytes): ${endpointsJson.substring(0, 200)}` + ) + } else { + debug( + `http_endpoints export exists but plugin type unknown for ${plugin.id}` + ) + return + } + } + + const endpoints = JSON.parse(endpointsJson) + debug(`Registering ${endpoints.length} HTTP endpoints for ${plugin.id}`) + + for (const endpoint of endpoints) { + const { method, path: endpointPath, handler } = endpoint + const routeMethod = method.toLowerCase() as + | 'get' + | 'post' + | 'put' + | 'delete' + + if (!['get', 'post', 'put', 'delete'].includes(routeMethod)) { + debug(`Skipping unsupported method: ${method}`) + continue + } + + debug(`Registering ${method} ${endpointPath} -> ${handler}`) + + router[routeMethod](endpointPath, async (req: Request, res: Response) => { + // Set a timeout to catch hangs (declare outside try so catch can access it) + let timeout: NodeJS.Timeout | null = null + + try { + debug( + `HTTP ${method} ${endpointPath} called - req.path: ${req.path}, req.url: ${req.url}` + ) + + // SPECIAL CASE: Handle /api/logs directly in Node.js for signalk-logviewer + // WASM cannot handle large data streams due to memory buffer limitations (~64KB) + if ( + plugin.id === 'signalk-logviewer' && + endpointPath === '/api/logs' && + method === 'GET' + ) { + debug(`Intercepting /api/logs for logviewer - handling in Node.js`) + return handleLogViewerRequest(req, res) + } + + // Build request context for WASM plugin + const requestContext = JSON.stringify({ + method: req.method, + path: req.path, + query: req.query, + params: req.params, + body: req.body, + headers: req.headers + }) + + debug( + `Calling WASM handler ${handler} with context: ${requestContext.substring(0, 200)}` + ) + + // Use AssemblyScript loader if available (handles strings automatically) + const asLoader = plugin.instance!.asLoader + let responseJson: string + + // Set a timeout to catch hangs + // Note: We cannot actually interrupt WASM execution, but we can detect hangs + let handlerTimedOut = false + timeout = setTimeout(() => { + handlerTimedOut = true + debug( + `ERROR: Handler ${handler} exceeded 10 second timeout - responding with error` + ) + debug( + `WARNING: WASM execution cannot be interrupted, server may remain partially blocked` + ) + // Send error response even though handler is still running + if (!res.headersSent) { + res.status(504).json({ + error: 'Plugin handler timeout', + message: + 'The WASM plugin took too long to respond. This indicates a performance issue in the plugin code.' + }) + } + }, 10000) // 10 second hard timeout + + if (asLoader) { + // AssemblyScript plugin with loader - strings handled automatically! + debug(`Using AssemblyScript loader for handler ${handler}`) + + const handlerFunc = asLoader.exports[handler] + if (typeof handlerFunc !== 'function') { + debug(`Handler function ${handler} not found in WASM exports`) + if (timeout) clearTimeout(timeout) + return res + .status(500) + .json({ error: `Handler function ${handler} not found` }) + } + + // Create an AssemblyScript string in WASM memory using __newString + const requestPtr = asLoader.exports.__newString(requestContext) + const requestLen = requestContext.length + + debug( + `Calling handler with string ptr=${requestPtr}, len=${requestLen}` + ) + + // Call handler - it returns an AssemblyScript string pointer + let asStringPtr: number + try { + debug(`About to call handler function...`) + asStringPtr = handlerFunc(requestPtr, requestLen) + debug( + `Handler function call completed, returned pointer: ${asStringPtr}` + ) + } catch (handlerError) { + const handlerErrMsg = + handlerError instanceof Error + ? handlerError.message + : String(handlerError) + debug(`ERROR: Handler function threw exception: ${handlerErrMsg}`) + debug( + `Stack: ${handlerError instanceof Error ? handlerError.stack : 'N/A'}` + ) + throw new Error(`WASM handler crashed: ${handlerErrMsg}`) + } + + // Check if we already sent timeout response + if (handlerTimedOut) { + debug(`Handler completed after timeout - discarding result`) + return + } + + // Use __getString to decode the AssemblyScript string + try { + debug(`About to decode string from pointer ${asStringPtr}...`) + responseJson = asLoader.exports.__getString(asStringPtr) + debug( + `String decoded successfully, length: ${responseJson.length}` + ) + debug( + `WASM handler returned (via loader): ${responseJson.substring(0, 500)}` + ) + } catch (decodeError) { + const decodeErrMsg = + decodeError instanceof Error + ? decodeError.message + : String(decodeError) + debug(`ERROR: Failed to decode response string: ${decodeErrMsg}`) + throw new Error(`Failed to decode WASM response: ${decodeErrMsg}`) + } + } else { + // Rust plugins use buffer-based string passing + debug( + `Using raw exports for handler ${handler} (Rust buffer-based)` + ) + const rawExports = plugin.instance!.instance.exports as any + const handlerFunc = rawExports[handler] + + if (typeof handlerFunc !== 'function') { + debug(`Handler function ${handler} not found in WASM exports`) + if (timeout) clearTimeout(timeout) + return res + .status(500) + .json({ error: `Handler function ${handler} not found` }) + } + + // Check if this is a Rust plugin with allocate/deallocate + if (typeof rawExports.allocate === 'function') { + // Rust buffer-based string passing (same pattern as PUT handlers) + const requestBytes = Buffer.from(requestContext, 'utf8') + const requestPtr = rawExports.allocate(requestBytes.length) + const responseMaxLen = 65536 // 64KB response buffer + const responsePtr = rawExports.allocate(responseMaxLen) + + // Write request to WASM memory + const memory = rawExports.memory as WebAssembly.Memory + const memView = new Uint8Array(memory.buffer) + memView.set(requestBytes, requestPtr) + + // Call handler: (request_ptr, request_len, response_ptr, response_max_len) -> written_len + const writtenLen = handlerFunc( + requestPtr, + requestBytes.length, + responsePtr, + responseMaxLen + ) + + // Read response from WASM memory + const responseBytes = new Uint8Array( + memory.buffer, + responsePtr, + writtenLen + ) + responseJson = new TextDecoder('utf-8').decode(responseBytes) + + // Deallocate buffers + if (typeof rawExports.deallocate === 'function') { + rawExports.deallocate(requestPtr, requestBytes.length) + rawExports.deallocate(responsePtr, responseMaxLen) + } + + debug( + `Rust handler returned ${writtenLen} bytes: ${responseJson.substring(0, 200)}` + ) + } else { + // Fallback for unknown plugin types - try direct call + responseJson = handlerFunc(requestContext) + } + } + + const response = JSON.parse(responseJson) + + // Set status code and headers + res.status(response.statusCode || 200) + if (response.headers) { + Object.entries(response.headers).forEach(([key, value]) => { + res.setHeader(key, value as string) + }) + } + + // Send body - try to parse as JSON if it's a string, otherwise send as-is + let body = response.body + if (typeof body === 'string') { + // Check if Content-Type is JSON + const contentType = response.headers?.['Content-Type'] || '' + if (contentType.includes('application/json')) { + try { + // Try to parse the string as JSON - if it's double-escaped, this will fix it + body = JSON.parse(body) + } catch (e) { + // If parsing fails, send the string as-is (might be plain text) + debug( + `Warning: Could not parse body as JSON, sending as string: ${e}` + ) + } + } + } + + if (timeout) clearTimeout(timeout) + debug(`Handler completed successfully, sending response`) + res.send(body) + } catch (error) { + if (timeout) clearTimeout(timeout) + const errorMsg = + error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : 'N/A' + debug(`Error in HTTP endpoint ${method} ${endpointPath}: ${errorMsg}`) + debug(`Stack trace: ${stack}`) + res.status(500).json({ error: errorMsg }) + } + }) + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Failed to register HTTP endpoints: ${errorMsg}`) + } + + debug(`Added plugin-specific routes for ${plugin.id}`) +} + +/** + * Set up REST API routes for a WASM plugin + */ +export function setupWasmPluginRoutes( + app: any, + plugin: WasmPlugin, + configPath: string, + updateWasmPluginConfig: ( + app: any, + pluginId: string, + configuration: any, + configPath: string + ) => Promise, + startWasmPlugin: (app: any, pluginId: string) => Promise, + unloadWasmPlugin: (app: any, pluginId: string) => Promise, + stopWasmPlugin: (pluginId: string) => Promise +): void { + const router = express.Router() + + // GET /plugins/:id - Get plugin info + router.get('/', (req: Request, res: Response) => { + res.json({ + enabled: plugin.enabled, + enabledByDefault: false, + id: plugin.id, + name: plugin.name, + version: plugin.version + }) + }) + + // POST /plugins/:id/config - Save plugin configuration + router.post('/config', async (req: Request, res: Response) => { + try { + debug(`POST /config received for WASM plugin: ${plugin.id}`) + debug(`Request body: ${JSON.stringify(req.body)}`) + + const newConfig = req.body + + debug( + `Current plugin state - enabled: ${plugin.enabled}, enableDebug: ${plugin.enableDebug}, configuration: ${JSON.stringify(plugin.configuration)}` + ) + + // Update enableDebug FIRST (before saving config) + if (typeof newConfig.enableDebug === 'boolean') { + debug( + `Updating enableDebug from ${plugin.enableDebug} to ${newConfig.enableDebug}` + ) + plugin.enableDebug = newConfig.enableDebug + } + + // Update enabled state SECOND (before saving config) + const enabledChanged = + typeof newConfig.enabled === 'boolean' && + newConfig.enabled !== plugin.enabled + if (enabledChanged) { + debug(`Updating enabled from ${plugin.enabled} to ${newConfig.enabled}`) + plugin.enabled = newConfig.enabled + } + + // Update plugin configuration and save everything to disk + debug( + `Calling updateWasmPluginConfig with: ${JSON.stringify(newConfig.configuration)}` + ) + await updateWasmPluginConfig( + app, + plugin.id, + newConfig.configuration, + configPath + ) + debug(`updateWasmPluginConfig completed`) + + // Start or stop plugin if enabled state changed + if (enabledChanged) { + if (plugin.enabled && plugin.status !== 'running') { + // If plugin was disabled at startup, instance will be null - need to load it first + if (!plugin.instance) { + debug(`Plugin was disabled at startup, loading WASM binary now...`) + + // Read package.json to get WASM path + const packageJson = require( + path.join( + plugin.packageLocation, + plugin.packageName, + 'package.json' + ) + ) + const wasmPath = path.join( + plugin.packageLocation, + plugin.packageName, + packageJson.wasmManifest + ) + const capabilities = plugin.metadata.capabilities + + // Create VFS root + const storagePaths = getPluginStoragePaths( + configPath, + plugin.id, + plugin.packageName + ) + + // Load WASM module + const runtime = getWasmRuntime() + const instance = await runtime.loadPlugin( + plugin.packageName, + wasmPath, + storagePaths.vfsRoot, + capabilities, + app + ) + + plugin.instance = instance + + // Get plugin metadata from WASM exports + const pluginName = instance.exports.name() + const schemaJson = instance.exports.schema() + const schema = schemaJson ? JSON.parse(schemaJson) : {} + + plugin.name = pluginName + plugin.schema = schema + + // Add plugin-specific HTTP endpoints to existing router + // (basic routes were already set up when plugin was registered as disabled) + setupPluginSpecificRoutes(plugin) + + debug(`Successfully loaded WASM binary for ${plugin.id}`) + } + + debug(`Plugin enabled, starting...`) + await startWasmPlugin(app, plugin.id) + } else if (!plugin.enabled && plugin.status === 'running') { + debug(`Plugin disabled, stopping...`) + await stopWasmPlugin(plugin.id) + } + } + + debug( + `Final plugin state - enabled: ${plugin.enabled}, status: ${plugin.status}` + ) + + const response = `Saved configuration for plugin ${plugin.id}` + debug(`Sending response: ${response}`) + res.json(response) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + const stack = error instanceof Error ? error.stack : '' + debug(`ERROR saving WASM plugin config: ${errorMsg}`) + debug(`Stack trace: ${stack}`) + console.error(`Error saving WASM plugin config:`, error) + res.status(500).json({ error: errorMsg }) + } + }) + + // GET /plugins/:id/config - Get plugin configuration + router.get('/config', (req: Request, res: Response) => { + const storagePaths = getPluginStoragePaths( + configPath, + plugin.id, + plugin.packageName + ) + const config = readPluginConfig(storagePaths.configFile) + + res.json({ + enabled: plugin.enabled, + enableDebug: plugin.enableDebug, + configuration: plugin.configuration, + ...config + }) + }) + + // Register the router for this plugin + app.use(backwardsCompat(`/plugins/${plugin.id}`), router) + + // Store router in plugin object for later removal + plugin.router = router + + // Register custom HTTP endpoints if plugin instance is loaded + setupPluginSpecificRoutes(plugin) + + debug(`Set up REST API routes for WASM plugin: ${plugin.id}`) +} diff --git a/src/wasm/loader/types.ts b/src/wasm/loader/types.ts new file mode 100644 index 000000000..e4ace66bf --- /dev/null +++ b/src/wasm/loader/types.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Plugin Type Definitions + * + * Shared types and interfaces for WASM plugin system + */ + +import { IRouter } from 'express' +import { + WasmPluginInstance, + WasmCapabilities, + WasmFormat +} from '../wasm-runtime' + +/** + * Plugin metadata extracted from package.json and manifest + */ +export interface WasmPluginMetadata { + id: string + name: string + packageName: string + version: string + wasmManifest: string + capabilities: WasmCapabilities + packageLocation: string +} + +/** + * Runtime plugin instance with state and configuration + */ +export interface WasmPlugin { + id: string + name: string + type: 'wasm' + packageName: string + version: string + enabled: boolean + enableDebug: boolean + keywords: string[] + packageLocation: string + configPath: string // Signal K config path for VFS/storage + metadata: WasmPluginMetadata + instance?: WasmPluginInstance + router?: IRouter // Express router for plugin routes + status: 'stopped' | 'starting' | 'running' | 'error' | 'crashed' + statusMessage?: string + errorMessage?: string + schema?: any + configuration?: any + crashCount: number + lastCrash?: Date + restartBackoff: number // milliseconds + description?: string + state?: string + format?: WasmFormat // Binary format: wasi-p1 or component-model +} diff --git a/src/wasm/loaders/component-loader.ts b/src/wasm/loaders/component-loader.ts new file mode 100644 index 000000000..e526dc4b4 --- /dev/null +++ b/src/wasm/loaders/component-loader.ts @@ -0,0 +1,205 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * Component Model Loader + * + * Loads WASI Component Model plugins using jco transpilation + */ + +import * as fs from 'fs' +import * as path from 'path' +import Debug from 'debug' +import { WasmPluginInstance, WasmCapabilities } from '../types' +import { createComponentSignalkApi } from '../bindings/signalk-api' +import { updateResourceProviderInstance } from '../bindings/resource-provider' +import { updateWeatherProviderInstance } from '../bindings/weather-provider' +import { updateRadarProviderInstance } from '../bindings/radar-provider' + +const debug = Debug('signalk:wasm:loader:component') + +// Try to use native Node.js WASI first, fall back to @wasmer/wasi +let WASI: any +try { + WASI = require('node:wasi').WASI +} catch { + WASI = require('@wasmer/wasi').WASI +} + +/** + * Load a WASI Component Model plugin using jco transpilation + * + * Component Model binaries (e.g., from .NET 10) cannot be loaded directly + * by Node.js WASI. We use jco to transpile them to JavaScript + WASI P1. + */ +export async function loadComponentModelPlugin( + pluginId: string, + wasmPath: string, + wasmBuffer: Buffer, + vfsRoot: string, + capabilities: WasmCapabilities, + app?: any +): Promise { + debug(`Loading Component Model plugin: ${pluginId}`) + + try { + // Import jco transpile dynamically + const { transpile } = await import('@bytecodealliance/jco') + + // Transpile the Component Model WASM to JavaScript bindings + debug(`Transpiling Component Model to JavaScript...`) + + // Get the output directory for transpiled files + const transpiledDir = path.join( + path.dirname(wasmPath), + '.jco-transpiled', + pluginId + ) + if (!fs.existsSync(transpiledDir)) { + fs.mkdirSync(transpiledDir, { recursive: true }) + } + + // Transpile the component + const { files } = await transpile(wasmBuffer, { + name: pluginId.replace(/[^a-zA-Z0-9]/g, '_'), + instantiation: 'async', + map: { + 'wasi:cli/*': '@bytecodealliance/preview2-shim/cli#*', + 'wasi:clocks/*': '@bytecodealliance/preview2-shim/clocks#*', + 'wasi:filesystem/*': '@bytecodealliance/preview2-shim/filesystem#*', + 'wasi:io/*': '@bytecodealliance/preview2-shim/io#*', + 'wasi:random/*': '@bytecodealliance/preview2-shim/random#*', + 'wasi:sockets/*': '@bytecodealliance/preview2-shim/sockets#*' + } + }) + + // Write transpiled files to disk + for (const [filename, content] of Object.entries(files)) { + const filePath = path.join(transpiledDir, filename) + const fileDir = path.dirname(filePath) + if (!fs.existsSync(fileDir)) { + fs.mkdirSync(fileDir, { recursive: true }) + } + fs.writeFileSync(filePath, content as Uint8Array) + debug(`Wrote transpiled file: ${filePath}`) + } + + // Find the main module file + const mainModulePath = path.join( + transpiledDir, + `${pluginId.replace(/[^a-zA-Z0-9]/g, '_')}.js` + ) + if (!fs.existsSync(mainModulePath)) { + const jsFiles = Object.keys(files).filter( + (f) => f.endsWith('.js') && !f.endsWith('.d.ts') + ) + if (jsFiles.length === 0) { + throw new Error('No JavaScript module found in transpiled output') + } + debug(`Available JS files: ${jsFiles.join(', ')}`) + } + + // Import the transpiled module + debug(`Importing transpiled module from: ${mainModulePath}`) + const componentModule = await import(`file://${mainModulePath}`) + + // Create imports for the component - provide Signal K API + const signalkApi = createComponentSignalkApi(pluginId, app) + + // Instantiate the component with imports + debug(`Instantiating component...`) + let componentInstance: any + + if (typeof componentModule.instantiate === 'function') { + componentInstance = await componentModule.instantiate((name: string) => { + if (name.startsWith('signalk:plugin/signalk-api')) { + return signalkApi + } + return {} + }) + } else { + componentInstance = componentModule + } + + debug(`Component instance created`) + + // Extract plugin interface exports + const pluginExports = + componentInstance['signalk:plugin/plugin@1.0.0'] || + componentInstance.plugin || + componentInstance + + // Map Component Model exports to our standard interface + const exports = { + id: () => { + const result = + pluginExports.pluginId?.() || pluginExports['plugin-id']?.() + return result || pluginId + }, + name: () => { + const result = + pluginExports.pluginName?.() || pluginExports['plugin-name']?.() + return result || pluginId + }, + schema: () => { + const result = + pluginExports.pluginSchema?.() || pluginExports['plugin-schema']?.() + return result || '{}' + }, + start: async (config: string) => { + const fn = pluginExports.pluginStart || pluginExports['plugin-start'] + if (fn) { + const result = await fn(config) + return typeof result === 'number' ? result : 0 + } + return 0 + }, + stop: () => { + const fn = pluginExports.pluginStop || pluginExports['plugin-stop'] + if (fn) { + const result = fn() + return typeof result === 'number' ? result : 0 + } + return 0 + } + } + + // Create a minimal WASI instance for compatibility + const wasi = new WASI({ + version: 'preview1', + env: { PLUGIN_ID: pluginId }, + args: [], + preopens: { '/': vfsRoot } + }) + + // Create plugin instance + const pluginInstance: WasmPluginInstance = { + pluginId, + wasmPath, + vfsRoot, + capabilities, + format: 'component-model', + wasi, + module: null as any, + instance: null as any, + exports, + componentModule: componentInstance + } + + // Update provider references + updateResourceProviderInstance(pluginId, pluginInstance) + updateWeatherProviderInstance(pluginId, pluginInstance) + updateRadarProviderInstance(pluginId, pluginInstance) + + debug(`Successfully loaded Component Model plugin: ${pluginId}`) + return pluginInstance + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Failed to load Component Model plugin ${pluginId}: ${errorMsg}`) + if (error instanceof Error && error.stack) { + debug(`Stack trace: ${error.stack}`) + } + throw new Error( + `Failed to load Component Model plugin ${pluginId}: ${errorMsg}` + ) + } +} diff --git a/src/wasm/loaders/index.ts b/src/wasm/loaders/index.ts new file mode 100644 index 000000000..8ce267051 --- /dev/null +++ b/src/wasm/loaders/index.ts @@ -0,0 +1,7 @@ +/** + * WASM Loaders + */ + +export * from './standard-loader' +export * from './jco-loader' +export * from './component-loader' diff --git a/src/wasm/loaders/jco-loader.ts b/src/wasm/loaders/jco-loader.ts new file mode 100644 index 000000000..a0ca5879c --- /dev/null +++ b/src/wasm/loaders/jco-loader.ts @@ -0,0 +1,247 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-require-imports */ +/** + * JCO Pre-Transpiled Loader + * + * Loads pre-transpiled jco JavaScript modules (already converted from Component Model) + */ + +import * as fs from 'fs' +import * as path from 'path' +import Debug from 'debug' +import { WasmPluginInstance, WasmCapabilities } from '../types' +import { createComponentSignalkApi } from '../bindings/signalk-api' +import { updateResourceProviderInstance } from '../bindings/resource-provider' +import { updateWeatherProviderInstance } from '../bindings/weather-provider' +import { updateRadarProviderInstance } from '../bindings/radar-provider' + +const debug = Debug('signalk:wasm:loader:jco') + +// Try to use native Node.js WASI first, fall back to @wasmer/wasi +let WASI: any +try { + WASI = require('node:wasi').WASI +} catch { + WASI = require('@wasmer/wasi').WASI +} + +/** + * Load a pre-transpiled jco plugin (JavaScript module) + * + * When wasmManifest points to a .js file, it's a pre-transpiled jco output + * that we can load directly as a JavaScript module. + */ +export async function loadJcoPlugin( + pluginId: string, + jsPath: string, + vfsRoot: string, + capabilities: WasmCapabilities, + app?: any +): Promise { + debug(`Loading pre-transpiled jco plugin: ${pluginId} from ${jsPath}`) + + try { + // Convert to file:// URL for dynamic import on Windows/Unix + const jsUrl = `file://${jsPath.replace(/\\/g, '/')}` + debug(`Importing module from: ${jsUrl}`) + + // Create Signal K API callbacks for the plugin + const signalkApi = createComponentSignalkApi(pluginId, app) + + // Try to load and inject callbacks into signalk-api.js before loading main module + const signalkApiPath = path.join(path.dirname(jsPath), 'signalk-api.js') + if (fs.existsSync(signalkApiPath)) { + const signalkApiUrl = `file://${signalkApiPath.replace(/\\/g, '/')}` + debug(`Injecting Signal K API callbacks from: ${signalkApiUrl}`) + try { + const signalkApiModule = await import(signalkApiUrl) + if (typeof signalkApiModule._setCallbacks === 'function') { + signalkApiModule._setCallbacks({ + debug: signalkApi.skDebug || signalkApi['sk-debug'], + setStatus: signalkApi.skSetStatus || signalkApi['sk-set-status'], + setError: signalkApi.skSetError || signalkApi['sk-set-error'], + handleMessage: + signalkApi.skHandleMessage || signalkApi['sk-handle-message'] + }) + debug(`Signal K API callbacks injected successfully`) + } + } catch (apiErr) { + debug(`Could not inject signalk-api callbacks: ${apiErr}`) + } + } + + // Import the pre-transpiled module + const componentModule = await import(jsUrl) + debug( + `Module imported, exports: ${Object.keys(componentModule).join(', ')}` + ) + + // Wait for WASM initialization if $init is exported (jco --tla-compat mode) + if ( + componentModule.$init && + typeof componentModule.$init.then === 'function' + ) { + debug(`Waiting for WASM $init promise...`) + try { + await componentModule.$init + debug(`WASM $init completed successfully`) + } catch (initError) { + debug(`WASM $init failed: ${initError}`) + throw initError + } + } + + // Debug: Log all exports from the componentModule after $init + debug( + `After $init, componentModule keys: ${Object.keys(componentModule).join(', ')}` + ) + if (componentModule.plugin) { + debug( + `componentModule.plugin keys: ${Object.keys(componentModule.plugin).join(', ')}` + ) + const pluginFuncs = componentModule.plugin + debug( + `pluginId type: ${typeof pluginFuncs.pluginId}, value: ${pluginFuncs.pluginId}` + ) + debug( + `pluginName type: ${typeof pluginFuncs.pluginName}, value: ${pluginFuncs.pluginName}` + ) + debug( + `pluginStart type: ${typeof pluginFuncs.pluginStart}, value: ${pluginFuncs.pluginStart}` + ) + } + + // Instantiate the component + let componentInstance: any + + if (typeof componentModule.instantiate === 'function') { + debug(`Instantiating via instantiate() function`) + componentInstance = await componentModule.instantiate( + (name: string) => { + debug(`Import resolver called for: ${name}`) + if (name.includes('signalk')) { + return signalkApi + } + return {} + }, + async (coreModule: string) => { + const corePath = path.join(path.dirname(jsPath), coreModule) + debug(`Loading core module: ${corePath}`) + const coreBuffer = fs.readFileSync(corePath) + return WebAssembly.compile(coreBuffer) + } + ) + } else if (componentModule.default) { + componentInstance = componentModule.default + } else { + componentInstance = componentModule + } + + debug( + `Component instance created, keys: ${Object.keys(componentInstance || {}).join(', ')}` + ) + + // Find the plugin exports + const pluginExports = + componentInstance?.['signalk:plugin/plugin@1.0.0'] || + componentInstance?.plugin || + componentInstance?.['signalk:plugin/plugin'] || + componentInstance + + debug( + `Plugin exports found, keys: ${Object.keys(pluginExports || {}).join(', ')}` + ) + + // Map Component Model exports to our standard interface + const exports = { + id: () => { + const fn = pluginExports?.pluginId || pluginExports?.['plugin-id'] + debug(`Calling pluginId, fn type: ${typeof fn}`) + try { + const result = typeof fn === 'function' ? fn() : fn + debug(`plugin_id() = ${result}`) + return result || pluginId + } catch (err) { + debug(`plugin_id() threw error: ${err}`) + throw err + } + }, + name: () => { + const fn = pluginExports?.pluginName || pluginExports?.['plugin-name'] + const result = typeof fn === 'function' ? fn() : fn + debug(`plugin_name() = ${result}`) + return result || pluginId + }, + schema: () => { + const fn = + pluginExports?.pluginSchema || pluginExports?.['plugin-schema'] + const result = typeof fn === 'function' ? fn() : fn + debug(`plugin_schema() = ${result}`) + return result || '{}' + }, + start: async (config: string) => { + const fn = pluginExports?.pluginStart || pluginExports?.['plugin-start'] + if (typeof fn === 'function') { + debug( + `Calling plugin_start with config: ${config.substring(0, 100)}...` + ) + const result = await fn(config) + debug(`plugin_start() = ${result}`) + return typeof result === 'number' ? result : 0 + } + debug(`No plugin_start function found`) + return 0 + }, + stop: () => { + const fn = pluginExports?.pluginStop || pluginExports?.['plugin-stop'] + if (typeof fn === 'function') { + debug(`Calling plugin_stop`) + const result = fn() + debug(`plugin_stop() = ${result}`) + return typeof result === 'number' ? result : 0 + } + debug(`No plugin_stop function found`) + return 0 + } + } + + // Create a minimal WASI instance for compatibility tracking + const wasi = new WASI({ + version: 'preview1', + env: { PLUGIN_ID: pluginId }, + args: [], + preopens: { '/': vfsRoot } + }) + + // Create plugin instance + const pluginInstance: WasmPluginInstance = { + pluginId, + wasmPath: jsPath, + vfsRoot, + capabilities, + format: 'component-model', + wasi, + module: null as any, + instance: null as any, + exports, + componentModule: componentInstance + } + + // Update provider references + updateResourceProviderInstance(pluginId, pluginInstance) + updateWeatherProviderInstance(pluginId, pluginInstance) + updateRadarProviderInstance(pluginId, pluginInstance) + + debug(`Successfully loaded pre-transpiled jco plugin: ${pluginId}`) + return pluginInstance + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Failed to load pre-transpiled plugin ${pluginId}: ${errorMsg}`) + if (error instanceof Error && error.stack) { + debug(`Stack: ${error.stack}`) + } + throw new Error( + `Failed to load pre-transpiled plugin ${pluginId}: ${errorMsg}` + ) + } +} diff --git a/src/wasm/loaders/standard-loader.ts b/src/wasm/loaders/standard-loader.ts new file mode 100644 index 000000000..c3bbc9908 --- /dev/null +++ b/src/wasm/loaders/standard-loader.ts @@ -0,0 +1,552 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/** + * Standard WASM Loader + * + * Loads WASI P1 plugins (AssemblyScript and Rust library plugins) + */ + +import * as fs from 'fs' +import Debug from 'debug' +import loader from '@assemblyscript/loader' +import { WasmPluginInstance, WasmCapabilities } from '../types' +import { createEnvImports } from '../bindings/env-imports' +import { updateResourceProviderInstance } from '../bindings/resource-provider' +import { updateWeatherProviderInstance } from '../bindings/weather-provider' +import { updateRadarProviderInstance } from '../bindings/radar-provider' +import { getNodeFetch } from '../utils/fetch-wrapper' + +const debug = Debug('signalk:wasm:loader:standard') + +// Try to use native Node.js WASI first, fall back to @wasmer/wasi +let WASI: any +try { + WASI = require('node:wasi').WASI +} catch { + WASI = require('@wasmer/wasi').WASI +} + +/** + * Load a standard WASI P1 plugin (AssemblyScript or Rust library) + */ +export async function loadStandardPlugin( + pluginId: string, + wasmPath: string, + wasmBuffer: Buffer, + vfsRoot: string, + capabilities: WasmCapabilities, + app?: any +): Promise { + debug(`Loading standard WASM plugin: ${pluginId} from ${wasmPath}`) + + // Create WASI instance with VFS isolation + debug(`Creating WASI instance for ${pluginId}`) + const wasi = new WASI({ + version: 'preview1', + env: { PLUGIN_ID: pluginId }, + args: [], + preopens: { '/': vfsRoot } + }) + debug(`WASI instance created`) + + // Compile WASM module + debug(`Compiling WASM module for inspection...`) + let module: WebAssembly.Module + try { + module = await WebAssembly.compile(wasmBuffer as BufferSource) + debug(`WASM module compiled successfully`) + } catch (compileError) { + debug(`WASM compilation failed: ${compileError}`) + throw compileError + } + + // Inspect module to determine plugin type + const imports = WebAssembly.Module.imports(module) + const moduleExports = WebAssembly.Module.exports(module) + debug(`Module has ${imports.length} imports, ${moduleExports.length} exports`) + debug( + `Module imports: ${JSON.stringify(imports.map((i) => `${i.module}.${i.name}`).slice(0, 20))}` + ) + + // Detect plugin type + // Note: plugin_id is optional since ID can be derived from package.json name + const hasPluginId = moduleExports.some((e) => e.name === 'plugin_id') + const hasPluginName = moduleExports.some((e) => e.name === 'plugin_name') + const hasPluginStart = moduleExports.some((e) => e.name === 'plugin_start') + const hasAllocate = moduleExports.some((e) => e.name === 'allocate') + const hasStart = moduleExports.some((e) => e.name === '_start') + + const isRustLibraryPlugin = hasPluginId && hasAllocate + const isRustPlugin = hasStart + // AssemblyScript plugins must have plugin_name and plugin_start (plugin_id is optional) + const isAssemblyScriptPlugin = + (hasPluginId || (hasPluginName && hasPluginStart)) && + !hasAllocate && + !hasStart + + debug( + `Plugin type detection: AS=${isAssemblyScriptPlugin}, RustLib=${isRustLibraryPlugin}, RustCmd=${isRustPlugin}` + ) + + // Get WASI imports + const wasiImports = ( + wasi.getImportObject ? wasi.getImportObject() : wasi.getImports(module) + ) as any + debug(`Got WASI imports`) + + // Refs that will be populated after instantiation + const memoryRef: { current: WebAssembly.Memory | null } = { current: null } + const rawExportsRef: { current: any } = { current: null } + const asLoaderRef: { current: any } = { current: null } + + // Create environment imports + const envImports = createEnvImports({ + pluginId, + capabilities, + app, + memoryRef, + rawExports: rawExportsRef, + asLoaderInstance: asLoaderRef + }) + + // Initialize as-fetch handler for network capability + let fetchHandler: any = null + let fetchImports = {} + + if (capabilities.network) { + debug(`Setting up as-fetch handler for network capability`) + const nodeFetch = getNodeFetch() + + // Create a wrapper that reads strings from WASM memory + const fetchWrapper = async ( + urlPtr: number | string | URL | RequestInfo, + init?: RequestInit + ) => { + let url: string + + if (typeof urlPtr === 'number') { + if (!memoryRef.current) { + throw new Error('WASM memory not available for string conversion') + } + + // Read AssemblyScript string from memory (UTF-16LE) + const SIZE_OFFSET = -4 + const memView = new Uint32Array(memoryRef.current.buffer) + const strLengthInBytes = memView[(urlPtr + SIZE_OFFSET) >>> 2] + const strLengthInChars = strLengthInBytes >>> 1 + const strView = new Uint16Array( + memoryRef.current.buffer, + urlPtr, + strLengthInChars + ) + url = String.fromCharCode(...Array.from(strView)) + debug(`Converted WASM string pointer ${urlPtr} to URL: ${url}`) + } else { + url = String(urlPtr) + } + + return nodeFetch(url, init) + } + + // Dynamic import for ESM-only as-fetch package + const { FetchHandler } = await import('as-fetch/bindings.raw.esm.js') + fetchHandler = new FetchHandler(fetchWrapper) + fetchImports = fetchHandler.imports + } + + // Instantiate the module + let instance: WebAssembly.Instance + let asLoaderInstance: any = null + let rawExports: any + + if (isAssemblyScriptPlugin) { + debug(`Using AssemblyScript loader for ${pluginId}`) + + asLoaderInstance = await loader.instantiate(module, { + wasi_snapshot_preview1: wasiImports.wasi_snapshot_preview1 || wasiImports, + env: envImports, + ...fetchImports + }) + + instance = asLoaderInstance.instance + rawExports = asLoaderInstance.exports + asLoaderRef.current = asLoaderInstance + debug(`AssemblyScript instance created with loader`) + } else { + // Standard WebAssembly instantiation for Rust plugins + instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasiImports.wasi_snapshot_preview1 || wasiImports, + env: envImports, + ...fetchImports + } as any) + rawExports = instance.exports as any + debug(`Standard WASM instance created`) + } + + // Set refs for use in callbacks + rawExportsRef.current = rawExports + if (rawExports.memory) { + memoryRef.current = rawExports.memory as WebAssembly.Memory + } + + // Store reference for Asyncify resume + let asyncifyResumeFunction: (() => any) | null = null + + // NOTE: Do NOT initialize as-fetch handler here! + // as-fetch uses global state that gets corrupted if multiple plugins are loaded in parallel. + // The handler is initialized right before plugin_start() is called, protected by a mutex. + + // Initialize based on plugin type + if (isRustPlugin) { + debug(`Initializing Rust command plugin: ${pluginId}`) + wasi.start(instance) + } else if (isRustLibraryPlugin) { + debug(`Initialized Rust library plugin: ${pluginId}`) + // Initialize WASI runtime without calling _start (for library plugins) + // This sets up fd_write and other syscalls properly + if (typeof wasi.initialize === 'function') { + debug(`Calling wasi.initialize() for Rust library plugin`) + wasi.initialize(instance) + } + // Also call _initialize if present (Rust static constructors) + if (rawExports._initialize) { + debug(`Calling _initialize for Rust library plugin`) + rawExports._initialize() + } + } else if (isAssemblyScriptPlugin) { + debug(`Initialized AssemblyScript plugin: ${pluginId}`) + } else { + throw new Error(`Unknown WASM plugin format for ${pluginId}`) + } + + // Create normalized export interface + const exports = createPluginExports( + isAssemblyScriptPlugin, + isRustLibraryPlugin, + asLoaderInstance, + rawExports, + () => asyncifyResumeFunction, + (fn) => { + asyncifyResumeFunction = fn + }, + fetchHandler, + capabilities + ) + + // Create setter for asyncify resume that can be used by external callers + const setAsyncifyResume = (fn: (() => any) | null) => { + asyncifyResumeFunction = fn + } + + const pluginInstance: WasmPluginInstance = { + pluginId, + wasmPath, + vfsRoot, + capabilities, + format: 'wasi-p1', + wasi, + module, + instance, + exports, + asLoader: asLoaderInstance, + setAsyncifyResume + } + + // Update provider references + updateResourceProviderInstance(pluginId, pluginInstance) + updateWeatherProviderInstance(pluginId, pluginInstance) + updateRadarProviderInstance(pluginId, pluginInstance) + + debug(`Successfully loaded WASM plugin: ${pluginId}`) + return pluginInstance +} + +/** + * Create normalized plugin exports based on plugin type + */ +function createPluginExports( + isAssemblyScriptPlugin: boolean, + isRustLibraryPlugin: boolean, + asLoaderInstance: any, + rawExports: any, + getAsyncifyResume: () => (() => any) | null, + setAsyncifyResume: (fn: (() => any) | null) => void, + fetchHandler: any, + capabilities: WasmCapabilities +) { + let idFunc: () => string + let nameFunc: () => string + let schemaFunc: () => string + let startFunc: (config: string) => number | Promise + let stopFunc: () => number + + if (isAssemblyScriptPlugin && asLoaderInstance) { + idFunc = () => { + const ptr = asLoaderInstance.exports.plugin_id() + return asLoaderInstance.exports.__getString(ptr) + } + nameFunc = () => { + const ptr = asLoaderInstance.exports.plugin_name() + return asLoaderInstance.exports.__getString(ptr) + } + schemaFunc = () => { + const ptr = asLoaderInstance.exports.plugin_schema() + return asLoaderInstance.exports.__getString(ptr) + } + + startFunc = async (config: string) => { + debug(`Calling plugin_start with config: ${config.substring(0, 100)}...`) + + // Re-initialize as-fetch handler to refresh ASYNCIFY_MEM view + // This is needed because memory may have grown since init(), detaching the old buffer view + if (fetchHandler && capabilities.network) { + debug(`Re-initializing as-fetch handler before plugin_start`) + fetchHandler.init(rawExports, () => { + debug(`FetchHandler calling main function to resume execution`) + const resumeFn = getAsyncifyResume() + if (resumeFn) { + resumeFn() + } + }) + } + + const encoder = new TextEncoder() + const configBytes = encoder.encode(config) + const configLen = configBytes.length + + const configPtr = asLoaderInstance.exports.__new(configLen, 0) + + const memory = asLoaderInstance.exports.memory.buffer + const memoryView = new Uint8Array(memory) + memoryView.set(configBytes, configPtr) + + let resumePromiseResolve: (() => void) | null = null + const resumePromise = new Promise((resolve) => { + resumePromiseResolve = resolve + }) + + setAsyncifyResume(() => { + debug(`Re-calling plugin_start to resume from rewind state`) + + // Check Asyncify state - as-fetch calls asyncify_start_rewind() before calling us + // State 0 = normal (rewind already completed), skip to avoid double-rewind + // State 1 = unwound (shouldn't happen, as-fetch would have started rewind) + // State 2 = rewinding (expected, proceed with resume) + if (typeof asLoaderInstance.exports.asyncify_get_state === 'function') { + const currentState = asLoaderInstance.exports.asyncify_get_state() + if (currentState === 0) { + debug( + `Plugin in normal state (state=0), rewind already completed, skipping` + ) + return + } + debug(`Asyncify state before resume: ${currentState}`) + } + + try { + // Re-read memory buffer in case it was detached during async operation + const currentMemory = asLoaderInstance.exports.memory.buffer + debug( + `Memory buffer size: ${currentMemory.byteLength}, configPtr: ${configPtr}, configLen: ${configLen}` + ) + + const resumeResult = asLoaderInstance.exports.plugin_start( + configPtr, + configLen + ) + if (resumePromiseResolve) { + resumePromiseResolve() + } + return resumeResult + } catch (err: any) { + debug(`Error during Asyncify rewind: ${err.message}`) + if (resumePromiseResolve) { + resumePromiseResolve() + } + throw err + } + }) + + const result = asLoaderInstance.exports.plugin_start(configPtr, configLen) + + if (typeof asLoaderInstance.exports.asyncify_get_state === 'function') { + const state = asLoaderInstance.exports.asyncify_get_state() + debug(`Asyncify state after plugin_start: ${state}`) + + if (state === 1) { + debug( + `Plugin is in unwound state - waiting for async operation to complete` + ) + await resumePromise + debug(`Async operation completed, plugin execution resumed`) + } else { + setAsyncifyResume(null) + } + } + + if (typeof asLoaderInstance.exports.__free === 'function') { + asLoaderInstance.exports.__free(configPtr) + } + + return result + } + stopFunc = () => asLoaderInstance.exports.plugin_stop() + } else if (isRustLibraryPlugin) { + debug(`Setting up Rust library plugin exports with buffer-based strings`) + + const callRustStringFunc = (funcName: string): string => { + const func = rawExports[funcName] + if (typeof func !== 'function') { + debug(`Warning: ${funcName} not found in exports`) + return '' + } + + const maxLen = 8192 + const allocate = rawExports.allocate + if (typeof allocate !== 'function') { + throw new Error('Rust plugin missing allocate export') + } + + const outPtr = allocate(maxLen) + if (!outPtr) { + throw new Error(`Failed to allocate ${maxLen} bytes for ${funcName}`) + } + + try { + const writtenLen = func(outPtr, maxLen) + if (writtenLen <= 0) { + debug(`${funcName} returned ${writtenLen}`) + return '' + } + + const memory = rawExports.memory as WebAssembly.Memory + const bytes = new Uint8Array(memory.buffer, outPtr, writtenLen) + const decoder = new TextDecoder('utf-8') + const result = decoder.decode(bytes) + debug(`${funcName} returned: ${result.substring(0, 100)}...`) + return result + } finally { + const deallocate = rawExports.deallocate + if (typeof deallocate === 'function') { + deallocate(outPtr, maxLen) + } + } + } + + idFunc = () => callRustStringFunc('plugin_id') + nameFunc = () => callRustStringFunc('plugin_name') + schemaFunc = () => callRustStringFunc('plugin_schema') + + startFunc = (config: string) => { + debug( + `Calling Rust plugin_start with config: ${config.substring(0, 100)}...` + ) + + const encoder = new TextEncoder() + const configBytes = encoder.encode(config) + const configLen = configBytes.length + + const allocate = rawExports.allocate + const configPtr = allocate(configLen) + + const memory = rawExports.memory as WebAssembly.Memory + const memoryView = new Uint8Array(memory.buffer) + memoryView.set(configBytes, configPtr) + + try { + const result = rawExports.plugin_start(configPtr, configLen) + debug(`plugin_start returned: ${result}`) + return result + } finally { + const deallocate = rawExports.deallocate + if (typeof deallocate === 'function') { + deallocate(configPtr, configLen) + } + } + } + + stopFunc = () => { + const result = rawExports.plugin_stop() + debug(`plugin_stop returned: ${result}`) + return result + } + } else { + // Rust command plugins or unknown + idFunc = rawExports.id + nameFunc = rawExports.name + schemaFunc = rawExports.schema + startFunc = rawExports.start + stopFunc = rawExports.stop + } + + // Wrap http_endpoints if it exists + const httpEndpointsFunc = rawExports.http_endpoints + ? isAssemblyScriptPlugin && asLoaderInstance + ? () => { + const ptr = asLoaderInstance.exports.http_endpoints() + return asLoaderInstance.exports.__getString(ptr) + } + : rawExports.http_endpoints + : undefined + + // Wrap poll if it exists (for plugins that need periodic execution) + const pollFunc = rawExports.poll + ? isAssemblyScriptPlugin && asLoaderInstance + ? () => asLoaderInstance.exports.poll() + : rawExports.poll + : undefined + + // Wrap delta_handler if it exists (for plugins that subscribe to deltas) + let deltaHandlerFunc: ((deltaJson: string) => void) | undefined = undefined + if (rawExports.delta_handler) { + if (isAssemblyScriptPlugin && asLoaderInstance) { + deltaHandlerFunc = (deltaJson: string) => { + // Pass delta JSON string to the WASM delta_handler + const ptr = asLoaderInstance.exports.__newString(deltaJson) + asLoaderInstance.exports.delta_handler(ptr) + } + } else if (isRustLibraryPlugin) { + // Rust library plugin: buffer-based string passing + deltaHandlerFunc = (deltaJson: string) => { + const encoder = new TextEncoder() + const deltaBytes = encoder.encode(deltaJson) + const deltaLen = deltaBytes.length + + const allocate = rawExports.allocate + if (typeof allocate !== 'function') { + debug('Rust plugin missing allocate export for delta_handler') + return + } + + const deltaPtr = allocate(deltaLen) + const memory = rawExports.memory as WebAssembly.Memory + const memoryView = new Uint8Array(memory.buffer) + memoryView.set(deltaBytes, deltaPtr) + + try { + rawExports.delta_handler(deltaPtr, deltaLen) + } finally { + const deallocate = rawExports.deallocate + if (typeof deallocate === 'function') { + deallocate(deltaPtr, deltaLen) + } + } + } + } else { + deltaHandlerFunc = rawExports.delta_handler + } + } + + return { + id: idFunc, + name: nameFunc, + schema: schemaFunc, + start: startFunc, + stop: stopFunc, + memory: rawExports.memory, + ...(httpEndpointsFunc && { http_endpoints: httpEndpointsFunc }), + ...(pollFunc && { poll: pollFunc }), + ...(deltaHandlerFunc && { delta_handler: deltaHandlerFunc }) + } +} diff --git a/src/wasm/types.ts b/src/wasm/types.ts new file mode 100644 index 000000000..14783ebce --- /dev/null +++ b/src/wasm/types.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Plugin Types + * + * Shared type definitions for WASM plugin system + */ + +/** + * Capabilities that can be granted to WASM plugins + */ +export interface WasmCapabilities { + network: boolean + storage: 'vfs-only' | 'none' + dataRead: boolean + dataWrite: boolean + serialPorts: boolean + putHandlers: boolean + httpEndpoints?: boolean + resourceProvider?: boolean // Can register as a resource provider + weatherProvider?: boolean // Can register as a weather provider + radarProvider?: boolean // Can register as a radar provider + rawSockets?: boolean // Can open UDP/TCP sockets for radar, NMEA, etc. +} + +/** + * WASM binary format types + */ +export type WasmFormat = 'wasi-p1' | 'component-model' | 'unknown' + +/** + * WASM plugin instance representing a loaded plugin + */ +export interface WasmPluginInstance { + pluginId: string + wasmPath: string + vfsRoot: string + capabilities: WasmCapabilities + format: WasmFormat // Binary format: wasi-p1 or component-model + wasi: any // WASI type varies between Node.js and @wasmer/wasi + module: WebAssembly.Module + instance: WebAssembly.Instance + exports: WasmPluginExports + // AssemblyScript loader instance (if AssemblyScript plugin) + asLoader?: any + // Component Model transpiled module (if Component Model plugin) + componentModule?: any + // Asyncify support: function to set the resume callback for async operations + setAsyncifyResume?: (fn: (() => any) | null) => void +} + +/** + * Standard exports expected from a WASM plugin + */ +export interface WasmPluginExports { + id: () => string + name: () => string + schema: () => string + start: (config: string) => number | Promise // 0 = success, non-zero = error (async for Asyncify support) + stop: () => number + memory?: WebAssembly.Memory + // Optional: HTTP endpoint registration + http_endpoints?: () => string // Returns JSON array of endpoint definitions + // Optional: Periodic polling - called every second when plugin is running + // Useful for plugins that need to poll hardware, sockets, or external systems + // Returns 0 on success, non-zero on error + poll?: () => number + // Optional: Delta handler - receives Signal K deltas as JSON strings + // Enables plugins to react to navigation data changes, course updates, etc. + delta_handler?: (deltaJson: string) => void +} + +/** + * Resource provider registration from a WASM plugin + */ +export interface WasmResourceProvider { + pluginId: string + resourceType: string + // Reference to the plugin instance for calling handlers + pluginInstance: WasmPluginInstance | null +} + +/** + * Weather provider registration from a WASM plugin + */ +export interface WasmWeatherProvider { + pluginId: string + providerName: string + // Reference to the plugin instance for calling handlers + pluginInstance: WasmPluginInstance | null +} + +/** + * Radar provider registration from a WASM plugin + */ +export interface WasmRadarProvider { + pluginId: string + providerName: string + // Reference to the plugin instance for calling handlers + pluginInstance: WasmPluginInstance | null +} + +/** + * Context passed to loader functions + */ +export interface LoaderContext { + pluginId: string + wasmPath: string + vfsRoot: string + capabilities: WasmCapabilities + app?: any + debug: (...args: any[]) => void +} diff --git a/src/wasm/utils/fetch-wrapper.ts b/src/wasm/utils/fetch-wrapper.ts new file mode 100644 index 000000000..fb6d083a9 --- /dev/null +++ b/src/wasm/utils/fetch-wrapper.ts @@ -0,0 +1,92 @@ +/** + * Fetch Wrapper for WASM Network Capability + * + * Provides a Node.js fetch wrapper that handles various header formats + */ + +import Debug from 'debug' + +const debug = Debug('signalk:wasm:fetch') + +let cachedFetch: typeof fetch | null = null + +/** + * Get a properly wrapped fetch function for use with as-fetch + */ +export function getNodeFetch(): typeof fetch { + if (cachedFetch) { + return cachedFetch + } + + try { + // Try to use native Node.js fetch (Node 18+) + const nativeFetch = globalThis.fetch + if (!nativeFetch) { + throw new Error('Native fetch not available') + } + + // Wrap native fetch to handle headers properly for as-fetch + cachedFetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const sanitizedInit = init ? { ...init } : {} + + // Ensure headers are in a format Node.js fetch accepts + if (sanitizedInit.headers) { + const headers = sanitizedInit.headers + + if ( + typeof headers === 'object' && + !Array.isArray(headers) && + !(headers instanceof Headers) + ) { + if ( + Object.getPrototypeOf(headers) === Object.prototype || + Object.getPrototypeOf(headers) === null + ) { + sanitizedInit.headers = headers as Record + } else { + const headersObj: Record = {} + try { + for (const [key, value] of Object.entries(headers)) { + headersObj[key] = String(value) + } + sanitizedInit.headers = headersObj + } catch (err) { + debug('Error converting headers:', err) + sanitizedInit.headers = {} + } + } + } else if (Array.isArray(headers)) { + const headersObj: Record = {} + for (const [key, value] of headers) { + headersObj[key] = value + } + sanitizedInit.headers = headersObj + } else if (headers instanceof Headers) { + const headersObj: Record = {} + headers.forEach((value, key) => { + headersObj[key] = value + }) + sanitizedInit.headers = headersObj + } else { + sanitizedInit.headers = {} + } + } else { + sanitizedInit.headers = {} + } + + return nativeFetch(input, sanitizedInit) + } + + return cachedFetch + } catch { + debug( + 'Warning: Native fetch not available, network capability will be limited' + ) + cachedFetch = async () => { + throw new Error( + 'Fetch not available - Node.js 18+ required for network capability' + ) + } + return cachedFetch + } +} diff --git a/src/wasm/utils/format-detection.ts b/src/wasm/utils/format-detection.ts new file mode 100644 index 000000000..60b7bc740 --- /dev/null +++ b/src/wasm/utils/format-detection.ts @@ -0,0 +1,39 @@ +/** + * WASM Format Detection + * + * Utilities for detecting WASM binary formats + */ + +import { WasmFormat } from '../types' + +/** + * Detect the format of a WASM binary by inspecting the magic bytes + * - WASI P1 modules start with: 0x00 0x61 0x73 0x6D 0x01 0x00 0x00 0x00 (version 1) + * - Component Model starts with: 0x00 0x61 0x73 0x6D 0x0d 0x00 0x01 0x00 (version 13/0x0d) + */ +export function detectWasmFormat(buffer: Buffer): WasmFormat { + if (buffer.length < 8) { + return 'unknown' + } + + // Check WASM magic number: \0asm + if ( + buffer[0] !== 0x00 || + buffer[1] !== 0x61 || + buffer[2] !== 0x73 || + buffer[3] !== 0x6d + ) { + return 'unknown' + } + + // Check version byte (byte 4) + const version = buffer[4] + + if (version === 0x01) { + return 'wasi-p1' + } else if (version === 0x0d) { + return 'component-model' + } + + return 'unknown' +} diff --git a/src/wasm/utils/index.ts b/src/wasm/utils/index.ts new file mode 100644 index 000000000..7b904e708 --- /dev/null +++ b/src/wasm/utils/index.ts @@ -0,0 +1,6 @@ +/** + * WASM Utilities + */ + +export * from './fetch-wrapper' +export * from './format-detection' diff --git a/src/wasm/wasm-runtime.ts b/src/wasm/wasm-runtime.ts new file mode 100644 index 000000000..9d8bc419e --- /dev/null +++ b/src/wasm/wasm-runtime.ts @@ -0,0 +1,286 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Runtime Management + * + * Handles WASM runtime initialization, module loading, + * and instance lifecycle management for Signal K WASM plugins. + * + * This is the main entry point that coordinates the various loaders + * and bindings for different WASM plugin formats. + */ + +import * as fs from 'fs' +import Debug from 'debug' + +// Re-export types for backward compatibility +export { + WasmCapabilities, + WasmFormat, + WasmPluginInstance, + WasmPluginExports, + WasmResourceProvider +} from './types' + +// Re-export utilities +export { detectWasmFormat } from './utils/format-detection' + +// Import loaders +import { loadStandardPlugin } from './loaders/standard-loader' +import { loadJcoPlugin } from './loaders/jco-loader' +import { loadComponentModelPlugin } from './loaders/component-loader' + +// Import bindings +import { + cleanupResourceProviders, + wasmResourceProviders +} from './bindings/resource-provider' +import { + cleanupWeatherProviders, + wasmWeatherProviders +} from './bindings/weather-provider' + +// Import utilities +import { detectWasmFormat } from './utils/format-detection' + +// Import types +import { WasmPluginInstance, WasmCapabilities } from './types' + +const debug = Debug('signalk:wasm:runtime') + +// Re-export provider maps for external access +export { wasmResourceProviders, wasmWeatherProviders } + +export class WasmRuntime { + private instances: Map = new Map() + private enabled: boolean = true + + constructor() { + debug('Initializing WASM runtime') + } + + /** + * Check if WASM support is enabled + */ + isEnabled(): boolean { + return this.enabled + } + + /** + * Enable or disable WASM plugin support + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled + debug(`WASM support ${enabled ? 'enabled' : 'disabled'}`) + } + + /** + * Load and instantiate a WASM plugin module + */ + async loadPlugin( + pluginId: string, + wasmPath: string, + vfsRoot: string, + capabilities: WasmCapabilities, + app?: any + ): Promise { + if (!this.enabled) { + throw new Error('WASM support is disabled') + } + + debug(`Loading WASM plugin: ${pluginId} from ${wasmPath}`) + + try { + // Ensure VFS root exists + if (!fs.existsSync(vfsRoot)) { + fs.mkdirSync(vfsRoot, { recursive: true }) + } + + let pluginInstance: WasmPluginInstance + + // Check if wasmPath points to a pre-transpiled jco JavaScript module + if (wasmPath.endsWith('.js')) { + debug(`Detected pre-transpiled jco module: ${wasmPath}`) + pluginInstance = await loadJcoPlugin( + pluginId, + wasmPath, + vfsRoot, + capabilities, + app + ) + } else { + // Load WASM binary and detect format + debug(`Reading WASM file: ${wasmPath}`) + const wasmBuffer = fs.readFileSync(wasmPath) + debug(`WASM file size: ${wasmBuffer.length} bytes`) + + const wasmFormat = detectWasmFormat(wasmBuffer) + debug(`Detected WASM format: ${wasmFormat}`) + + if (wasmFormat === 'component-model') { + debug(`Component Model detected - using jco transpilation`) + pluginInstance = await loadComponentModelPlugin( + pluginId, + wasmPath, + wasmBuffer, + vfsRoot, + capabilities, + app + ) + } else { + // Standard WASI P1 plugin (AssemblyScript or Rust) + pluginInstance = await loadStandardPlugin( + pluginId, + wasmPath, + wasmBuffer, + vfsRoot, + capabilities, + app + ) + } + } + + // Store the instance + this.instances.set(pluginId, pluginInstance) + + debug(`Successfully loaded WASM plugin: ${pluginId}`) + return pluginInstance + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Failed to load WASM plugin ${pluginId}: ${errorMsg}`) + throw new Error(`Failed to load WASM plugin ${pluginId}: ${errorMsg}`) + } + } + + /** + * Unload a WASM plugin instance + * @param pluginId The plugin ID to unload + * @param app Optional Signal K app reference for proper API cleanup + */ + async unloadPlugin(pluginId: string, app?: any): Promise { + const instance = this.instances.get(pluginId) + if (!instance) { + debug(`Plugin ${pluginId} not found in loaded instances`) + return + } + + debug(`Unloading WASM plugin: ${pluginId}`) + + try { + // Call stop if available + if (instance.exports.stop) { + instance.exports.stop() + } + + // Clean up resource provider registrations for this plugin + // Pass app to also unregister from ResourcesApi + cleanupResourceProviders(pluginId, app) + + // Clean up weather provider registrations for this plugin + // Pass app to also unregister from WeatherApi + cleanupWeatherProviders(pluginId, app) + + // Remove from instances + this.instances.delete(pluginId) + + // Let GC clean up the instance + debug(`Successfully unloaded WASM plugin: ${pluginId}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Error unloading WASM plugin ${pluginId}: ${errorMsg}`) + throw error + } + } + + /** + * Reload a WASM plugin (unload + load) + */ + async reloadPlugin(pluginId: string): Promise { + const oldInstance = this.instances.get(pluginId) + if (!oldInstance) { + throw new Error(`Plugin ${pluginId} not loaded`) + } + + const { wasmPath, vfsRoot, capabilities } = oldInstance + + // Unload old instance + await this.unloadPlugin(pluginId) + + // Load new instance + return this.loadPlugin(pluginId, wasmPath, vfsRoot, capabilities) + } + + /** + * Get a loaded plugin instance + */ + getInstance(pluginId: string): WasmPluginInstance | undefined { + return this.instances.get(pluginId) + } + + /** + * Get all loaded plugin instances + */ + getAllInstances(): WasmPluginInstance[] { + return Array.from(this.instances.values()) + } + + /** + * Check if a plugin is loaded + */ + isPluginLoaded(pluginId: string): boolean { + return this.instances.has(pluginId) + } + + /** + * Shutdown the WASM runtime and unload all plugins + */ + async shutdown(): Promise { + debug('Shutting down WASM runtime') + + const pluginIds = Array.from(this.instances.keys()) + for (const pluginId of pluginIds) { + try { + await this.unloadPlugin(pluginId) + } catch (error) { + debug(`Error unloading plugin ${pluginId} during shutdown:`, error) + } + } + + this.instances.clear() + debug('WASM runtime shutdown complete') + } +} + +// Global singleton instance +let runtimeInstance: WasmRuntime | null = null + +/** + * Get the global WASM runtime instance + */ +export function getWasmRuntime(): WasmRuntime { + if (!runtimeInstance) { + runtimeInstance = new WasmRuntime() + } + return runtimeInstance +} + +/** + * Initialize the WASM runtime + */ +export function initializeWasmRuntime(): WasmRuntime { + if (runtimeInstance) { + debug('WASM runtime already initialized') + return runtimeInstance + } + + runtimeInstance = new WasmRuntime() + return runtimeInstance +} + +/** + * Reset the WASM runtime singleton (for hotplug support) + * This should be called after shutdown to allow re-initialization + */ +export function resetWasmRuntime(): void { + debug('Resetting WASM runtime singleton') + runtimeInstance = null +} diff --git a/src/wasm/wasm-serverapi.ts b/src/wasm/wasm-serverapi.ts new file mode 100644 index 000000000..3ba225427 --- /dev/null +++ b/src/wasm/wasm-serverapi.ts @@ -0,0 +1,393 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM ServerAPI FFI Bridge + * + * Provides the FFI (Foreign Function Interface) bridge between WASM plugins + * and the Signal K ServerAPI. Enforces capability restrictions and handles + * serialization across the WASM boundary. + */ + +/// + +import Debug from 'debug' +import { SKVersion } from '@signalk/server-api' +import { getWasmPlugin } from './loader' +import { + getPluginStoragePaths, + readPluginConfig, + writePluginConfig +} from './wasm-storage' + +const debug = Debug('signalk:wasm:serverapi') + +export interface ServerAPIBridge { + app: any + configPath: string +} + +/** + * Create ServerAPI FFI functions for a WASM plugin + * + * These functions will be imported by the WASM module and provide + * access to Signal K server capabilities based on declared permissions. + */ +export function createServerAPIBridge( + app: any, + pluginId: string, + configPath: string +): any { + const plugin = getWasmPlugin(pluginId) + if (!plugin) { + throw new Error(`Plugin ${pluginId} not found`) + } + + const capabilities = plugin.metadata.capabilities + + return { + // Delta Handler API + 'delta-handler': { + /** + * Handle delta message from plugin + * + * @param pluginIdParam - Plugin identifier + * @param deltaJson - Delta message as JSON string + * @param version - Signal K version: 1 = v1 (default), 2 = v2 + * + * Plugins should use v1 for regular navigation data. + * Use v2 for Course API paths and other v2-specific data. + */ + handleMessage: ( + pluginIdParam: string, + deltaJson: string, + version: number = 1 + ) => { + if (!capabilities.dataWrite) { + throw new Error(`Plugin ${pluginId} lacks dataWrite capability`) + } + + try { + const delta = JSON.parse(deltaJson) + const skVersion = version === 2 ? SKVersion.v2 : SKVersion.v1 + debug(`Plugin ${pluginId} emitting delta (${skVersion}):`, delta) + + // Forward to server's handleMessage with version + if (app.handleMessage) { + app.handleMessage(pluginId, delta, skVersion) + } else { + debug('Warning: app.handleMessage not available') + } + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error) + debug(`Error handling delta from ${pluginId}: ${errorMsg}`) + throw error + } + } + }, + + // Plugin Config API + 'plugin-config': { + /** + * Read plugin configuration + */ + readPluginOptions: (): string => { + const storagePaths = getPluginStoragePaths( + configPath, + pluginId, + plugin.packageName + ) + const config = readPluginConfig(storagePaths.configFile) + return JSON.stringify(config.configuration || {}) + }, + + /** + * Save plugin configuration + */ + savePluginOptions: (configJson: string): number => { + try { + const configuration = JSON.parse(configJson) + const storagePaths = getPluginStoragePaths( + configPath, + pluginId, + plugin.packageName + ) + const config = { + enabled: plugin.enabled, + configuration + } + writePluginConfig(storagePaths.configFile, config) + plugin.configuration = configuration + debug(`Plugin ${pluginId} saved configuration`) + return 0 // Success + } catch (error) { + const errorMsg = + error instanceof Error ? error.message : String(error) + debug(`Error saving config for ${pluginId}: ${errorMsg}`) + return 1 // Error + } + }, + + /** + * Get data directory path (VFS root from plugin perspective) + */ + getDataDirPath: (): string => { + // Plugin sees "/" as its VFS root + return '/' + } + }, + + // Plugin Status API + 'plugin-status': { + /** + * Set plugin status message + */ + setPluginStatus: (message: string) => { + plugin.statusMessage = message + debug(`Plugin ${pluginId} status: ${message}`) + + // Update in app status if available + if (app.setPluginStatus) { + app.setPluginStatus(pluginId, message) + } + }, + + /** + * Set plugin error message + */ + setPluginError: (message: string) => { + plugin.errorMessage = message + plugin.status = 'error' + debug(`Plugin ${pluginId} error: ${message}`) + + // Update in app status if available + if (app.setPluginError) { + app.setPluginError(pluginId, message) + } + }, + + /** + * Debug logging + */ + debug: (message: string) => { + debug(`[${pluginId}] ${message}`) + }, + + /** + * Error logging + */ + error: (message: string) => { + debug(`[${pluginId}] ERROR: ${message}`) + } + }, + + // Full Model API (Signal K full data model access) + 'full-model': { + /** + * Get data from vessel.self path + */ + getSelfPath: (path: string): string | null => { + if (!capabilities.dataRead) { + throw new Error(`Plugin ${pluginId} lacks dataRead capability`) + } + + try { + const value = app.getSelfPath ? app.getSelfPath(path) : undefined + return value !== undefined ? JSON.stringify(value) : null + } catch (error) { + debug(`Error getting self path ${path} for ${pluginId}:`, error) + return null + } + }, + + /** + * Get data from any context path + */ + getPath: (path: string): string | null => { + if (!capabilities.dataRead) { + throw new Error(`Plugin ${pluginId} lacks dataRead capability`) + } + + try { + const value = app.getPath ? app.getPath(path) : undefined + return value !== undefined ? JSON.stringify(value) : null + } catch (error) { + debug(`Error getting path ${path} for ${pluginId}:`, error) + return null + } + } + } + } +} + +/** + * Create WASM import object with ServerAPI functions + * + * This generates the WebAssembly imports that will be available to the plugin. + * In Phase 1, we use a simplified approach. Full WIT bindings will be added later. + */ +export function createWasmImports( + app: any, + pluginId: string, + configPath: string +): WebAssembly.Imports { + const bridge = createServerAPIBridge(app, pluginId, configPath) + + // Create flat import object for WASM + // Note: This is a simplified version for Phase 1 + // Full WIT integration will provide proper type-safe bindings + return { + env: { + // Delta handling + sk_handle_message: ( + deltaPtr: number, + deltaLen: number, + memory: WebAssembly.Memory + ) => { + const deltaJson = readStringFromMemory(memory, deltaPtr, deltaLen) + bridge['delta-handler'].handleMessage(pluginId, deltaJson) + }, + + // Configuration + sk_read_config: ( + bufPtr: number, + bufLen: number, + memory: WebAssembly.Memory + ): number => { + const configJson = bridge['plugin-config'].readPluginOptions() + return writeStringToMemory(memory, bufPtr, bufLen, configJson) + }, + + sk_save_config: ( + configPtr: number, + configLen: number, + memory: WebAssembly.Memory + ): number => { + const configJson = readStringFromMemory(memory, configPtr, configLen) + return bridge['plugin-config'].savePluginOptions(configJson) + }, + + // Status + sk_set_status: ( + msgPtr: number, + msgLen: number, + memory: WebAssembly.Memory + ) => { + const message = readStringFromMemory(memory, msgPtr, msgLen) + bridge['plugin-status'].setPluginStatus(message) + }, + + sk_set_error: ( + msgPtr: number, + msgLen: number, + memory: WebAssembly.Memory + ) => { + const message = readStringFromMemory(memory, msgPtr, msgLen) + bridge['plugin-status'].setPluginError(message) + }, + + sk_debug: ( + msgPtr: number, + msgLen: number, + memory: WebAssembly.Memory + ) => { + const message = readStringFromMemory(memory, msgPtr, msgLen) + bridge['plugin-status'].debug(message) + }, + + // Data model + sk_get_self_path: ( + pathPtr: number, + pathLen: number, + bufPtr: number, + bufLen: number, + memory: WebAssembly.Memory + ): number => { + const path = readStringFromMemory(memory, pathPtr, pathLen) + const value = bridge['full-model'].getSelfPath(path) + if (value === null) { + return 0 // Not found + } + return writeStringToMemory(memory, bufPtr, bufLen, value) + }, + + sk_get_path: ( + pathPtr: number, + pathLen: number, + bufPtr: number, + bufLen: number, + memory: WebAssembly.Memory + ): number => { + const path = readStringFromMemory(memory, pathPtr, pathLen) + const value = bridge['full-model'].getPath(path) + if (value === null) { + return 0 // Not found + } + return writeStringToMemory(memory, bufPtr, bufLen, value) + } + } + } +} + +/** + * Read a string from WASM memory + */ +function readStringFromMemory( + memory: WebAssembly.Memory, + ptr: number, + len: number +): string { + const buffer = new Uint8Array(memory.buffer, ptr, len) + const decoder = new TextDecoder() + return decoder.decode(buffer) +} + +/** + * Write a string to WASM memory + * Returns the number of bytes written, or 0 if buffer too small + */ +function writeStringToMemory( + memory: WebAssembly.Memory, + ptr: number, + maxLen: number, + str: string +): number { + const encoder = new TextEncoder() + const encoded = encoder.encode(str) + + if (encoded.length > maxLen) { + debug(`Buffer too small: need ${encoded.length}, have ${maxLen}`) + return 0 + } + + const buffer = new Uint8Array(memory.buffer, ptr, maxLen) + buffer.set(encoded) + + return encoded.length +} + +/** + * Call a WASM plugin export with error handling + */ +export function callWasmExport( + pluginId: string, + exportName: string, + ...args: any[] +): T { + const plugin = getWasmPlugin(pluginId) + if (!plugin || !plugin.instance) { + throw new Error(`Plugin ${pluginId} not loaded`) + } + + try { + const exportFn = (plugin.instance.exports as any)[exportName] + if (typeof exportFn !== 'function') { + throw new Error(`Export ${exportName} not found or not a function`) + } + + return exportFn(...args) as T + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Error calling ${exportName} on ${pluginId}: ${errorMsg}`) + throw error + } +} diff --git a/src/wasm/wasm-storage.ts b/src/wasm/wasm-storage.ts new file mode 100644 index 000000000..8581f29e9 --- /dev/null +++ b/src/wasm/wasm-storage.ts @@ -0,0 +1,278 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Plugin Virtual Filesystem (VFS) Management + * + * Handles isolated storage for WASM plugins using WASI virtual filesystem. + * Each plugin gets its own VFS root for secure, sandboxed file access. + */ + +import * as fs from 'fs' +import * as path from 'path' +import Debug from 'debug' +import { derivePluginId } from '../pluginid' + +const debug = Debug('signalk:wasm:storage') + +export interface PluginStoragePaths { + // Root directory for all plugin data + pluginDataRoot: string + + // Server-managed config file (outside VFS) + configFile: string + + // VFS root (what plugin sees as "/") + vfsRoot: string + + // Standard VFS subdirectories + vfsData: string // /data (persistent storage) + vfsConfig: string // /config (plugin-managed config) + vfsTmp: string // /tmp (temporary files) +} + +/** + * Get storage paths for a WASM plugin + * + * @param configPath - Server config directory path + * @param pluginId - Plugin ID (e.g., "hello-assemblyscript") - used for config file to match regular plugins + * @param packageName - NPM package name (e.g., "@signalk/hello-assemblyscript") - used for VFS directory + */ +export function getPluginStoragePaths( + configPath: string, + pluginId: string, + packageName: string +): PluginStoragePaths { + // Config file goes directly in plugin-config-data/ like regular plugins + const configDataPath = path.join(configPath, 'plugin-config-data') + + // Use plugin ID for config file (matches regular Node.js plugins) + const configFile = path.join(configDataPath, `${pluginId}.json`) + + // Use sanitized package name for VFS directory (for isolation) + // Use same pattern as plugin ID: @ → _, / → _ + // @signalk/hello-assemblyscript -> _signalk_hello-assemblyscript + const sanitizedPackageName = derivePluginId(packageName) + const pluginDataRoot = path.join(configDataPath, sanitizedPackageName) + const vfsRoot = path.join(pluginDataRoot, 'vfs') + + return { + pluginDataRoot, + configFile, // e.g., ~/.signalk/plugin-config-data/_signalk_example-hello-assemblyscript.json + vfsRoot, // e.g., ~/.signalk/plugin-config-data/_signalk_example-hello-assemblyscript/vfs/ + vfsData: path.join(vfsRoot, 'data'), + vfsConfig: path.join(vfsRoot, 'config'), + vfsTmp: path.join(vfsRoot, 'tmp') + } +} + +/** + * Initialize VFS structure for a WASM plugin + */ +export function initializePluginVfs(paths: PluginStoragePaths): void { + debug(`Initializing VFS for plugin at ${paths.vfsRoot}`) + + try { + // Create VFS root and subdirectories + const dirsToCreate = [ + paths.pluginDataRoot, + paths.vfsRoot, + paths.vfsData, + paths.vfsConfig, + paths.vfsTmp + ] + + for (const dir of dirsToCreate) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + debug(`Created directory: ${dir}`) + } + } + + debug(`VFS initialized successfully at ${paths.vfsRoot}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Failed to initialize VFS: ${errorMsg}`) + throw new Error(`Failed to initialize plugin VFS: ${errorMsg}`) + } +} + +/** + * Read plugin configuration from server-managed config file + */ +export function readPluginConfig(configFile: string): any { + try { + if (!fs.existsSync(configFile)) { + debug(`Config file not found: ${configFile}, returning default config`) + // Note: Do NOT include configuration key - UI shows "Configure" button when configuration is null/undefined + return { + enabled: false + } + } + + const configData = fs.readFileSync(configFile, 'utf8') + return JSON.parse(configData) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Error reading plugin config: ${errorMsg}`) + // Note: Do NOT include configuration key - UI shows "Configure" button when configuration is null/undefined + return { + enabled: false + } + } +} + +/** + * Write plugin configuration to server-managed config file + */ +export function writePluginConfig(configFile: string, config: any): void { + try { + const configDir = path.dirname(configFile) + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) + } + + fs.writeFileSync(configFile, JSON.stringify(config, null, 2), 'utf8') + debug(`Wrote plugin config to ${configFile}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Error writing plugin config: ${errorMsg}`) + throw new Error(`Failed to write plugin config: ${errorMsg}`) + } +} + +/** + * Migrate data from Node.js plugin format to WASM VFS format + * + * Copies files from legacy Node.js plugin data directory to VFS /data directory. + * Legacy files are preserved for rollback. + */ +export function migrateFromNodeJs( + legacyDataDir: string, + vfsDataDir: string, + filesToMigrate: string[] +): void { + debug(`Migrating data from ${legacyDataDir} to ${vfsDataDir}`) + + if (!fs.existsSync(legacyDataDir)) { + debug('Legacy data directory does not exist, skipping migration') + return + } + + if (!fs.existsSync(vfsDataDir)) { + fs.mkdirSync(vfsDataDir, { recursive: true }) + } + + let migratedCount = 0 + + for (const filename of filesToMigrate) { + const legacyPath = path.join(legacyDataDir, filename) + const vfsPath = path.join(vfsDataDir, filename) + + if (fs.existsSync(legacyPath)) { + try { + // Copy file to VFS (preserve legacy file) + fs.copyFileSync(legacyPath, vfsPath) + debug(`Migrated: ${filename}`) + migratedCount++ + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Failed to migrate ${filename}: ${errorMsg}`) + } + } + } + + debug( + `Migration complete: ${migratedCount}/${filesToMigrate.length} files migrated` + ) +} + +/** + * Clean up VFS temporary files + */ +export function cleanupVfsTmp(vfsTmpDir: string): void { + try { + if (!fs.existsSync(vfsTmpDir)) { + return + } + + const files = fs.readdirSync(vfsTmpDir) + let deletedCount = 0 + + for (const file of files) { + try { + const filePath = path.join(vfsTmpDir, file) + const stats = fs.statSync(filePath) + + if (stats.isFile()) { + fs.unlinkSync(filePath) + deletedCount++ + } + } catch (error) { + debug(`Failed to delete temp file ${file}:`, error) + } + } + + debug(`Cleaned up ${deletedCount} temporary files from ${vfsTmpDir}`) + } catch (error) { + debug(`Error cleaning up temp directory:`, error) + } +} + +/** + * Get disk usage for a plugin's VFS + */ +export function getVfsDiskUsage(vfsRoot: string): { + totalBytes: number + fileCount: number +} { + let totalBytes = 0 + let fileCount = 0 + + function walkDirectory(dir: string): void { + try { + if (!fs.existsSync(dir)) { + return + } + + const entries = fs.readdirSync(dir) + + for (const entry of entries) { + const entryPath = path.join(dir, entry) + const stats = fs.statSync(entryPath) + + if (stats.isFile()) { + totalBytes += stats.size + fileCount++ + } else if (stats.isDirectory()) { + walkDirectory(entryPath) + } + } + } catch (error) { + debug(`Error reading directory ${dir}:`, error) + } + } + + walkDirectory(vfsRoot) + + return { totalBytes, fileCount } +} + +/** + * Delete all VFS data for a plugin + */ +export function deletePluginVfs(paths: PluginStoragePaths): void { + debug(`Deleting VFS for plugin at ${paths.vfsRoot}`) + + try { + if (fs.existsSync(paths.vfsRoot)) { + fs.rmSync(paths.vfsRoot, { recursive: true, force: true }) + debug(`Deleted VFS directory: ${paths.vfsRoot}`) + } + + // Note: We keep the server-managed config file for plugin settings + debug('VFS deletion complete') + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + debug(`Error deleting VFS: ${errorMsg}`) + throw new Error(`Failed to delete plugin VFS: ${errorMsg}`) + } +} diff --git a/src/wasm/wasm-subscriptions.ts b/src/wasm/wasm-subscriptions.ts new file mode 100644 index 000000000..e00246b1f --- /dev/null +++ b/src/wasm/wasm-subscriptions.ts @@ -0,0 +1,281 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * WASM Plugin Delta Subscription Management + * + * Manages delta subscriptions for WASM plugins, including: + * - Subscription pattern matching + * - Buffering during hot-reload + * - Subscription state preservation across reloads + */ + +import Debug from 'debug' + +const debug = Debug('signalk:wasm:subscriptions') + +export interface DeltaSubscription { + pluginId: string + pattern: string // Path pattern like "navigation.*" or "*" + callback: (delta: any) => void +} + +export interface Delta { + context: string + updates: Array<{ + source: any + timestamp: string + values: Array<{ + path: string + value: any + }> + }> +} + +export class WasmSubscriptionManager { + // Active subscriptions by plugin ID + private subscriptions: Map = new Map() + + // Buffered deltas during reload + private buffers: Map = new Map() + + // Buffering state + private buffering: Set = new Set() + + /** + * Register a delta subscription for a plugin + */ + register( + pluginId: string, + pattern: string, + callback: (delta: any) => void + ): void { + if (!this.subscriptions.has(pluginId)) { + this.subscriptions.set(pluginId, []) + } + + const subscription: DeltaSubscription = { + pluginId, + pattern, + callback + } + + this.subscriptions.get(pluginId)!.push(subscription) + debug(`Registered subscription for ${pluginId}: ${pattern}`) + } + + /** + * Unregister all subscriptions for a plugin + */ + unregister(pluginId: string): void { + const count = this.subscriptions.get(pluginId)?.length || 0 + this.subscriptions.delete(pluginId) + debug(`Unregistered ${count} subscriptions for ${pluginId}`) + } + + /** + * Get all subscriptions for a plugin + */ + getSubscriptions(pluginId: string): DeltaSubscription[] { + return this.subscriptions.get(pluginId) || [] + } + + /** + * Check if a delta path matches a subscription pattern + */ + private matchesPattern(path: string, pattern: string): boolean { + if (pattern === '*') { + return true + } + + // Simple glob-style matching + // "navigation.*" matches "navigation.position", "navigation.courseOverGroundTrue", etc. + const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + + const regex = new RegExp(`^${regexPattern}$`) + return regex.test(path) + } + + /** + * Route a delta to subscribed plugins + */ + routeDelta(delta: Delta): void { + for (const [pluginId, subs] of this.subscriptions) { + // Check if buffering for this plugin + if (this.buffering.has(pluginId)) { + this.bufferDelta(pluginId, delta) + continue + } + + // Check if any subscription matches delta paths + for (const sub of subs) { + let matches = false + + for (const update of delta.updates) { + for (const pathValue of update.values) { + if (this.matchesPattern(pathValue.path, sub.pattern)) { + matches = true + break + } + } + if (matches) break + } + + if (matches) { + try { + sub.callback(delta) + } catch (error) { + debug(`Error in subscription callback for ${pluginId}:`, error) + } + break // Only call once per plugin per delta + } + } + } + } + + /** + * Start buffering deltas for a plugin (during reload) + */ + startBuffering(pluginId: string): void { + debug(`Started buffering deltas for ${pluginId}`) + this.buffering.add(pluginId) + this.buffers.set(pluginId, []) + } + + /** + * Stop buffering and return buffered deltas + */ + stopBuffering(pluginId: string): Delta[] { + debug(`Stopped buffering deltas for ${pluginId}`) + this.buffering.delete(pluginId) + + const buffered = this.buffers.get(pluginId) || [] + this.buffers.delete(pluginId) + + debug(`Returning ${buffered.length} buffered deltas for ${pluginId}`) + return buffered + } + + /** + * Buffer a delta for a plugin + */ + private bufferDelta(pluginId: string, delta: Delta): void { + if (!this.buffers.has(pluginId)) { + this.buffers.set(pluginId, []) + } + + const buffer = this.buffers.get(pluginId)! + buffer.push(delta) + + // Limit buffer size to prevent memory issues + const MAX_BUFFER_SIZE = 1000 + if (buffer.length > MAX_BUFFER_SIZE) { + buffer.shift() // Remove oldest + debug(`Buffer overflow for ${pluginId}, dropped oldest delta`) + } + } + + /** + * Redirect delta routing to buffer for a plugin + */ + redirectToBuffer(pluginId: string): void { + this.startBuffering(pluginId) + } + + /** + * Restore normal delta routing for a plugin + */ + restore(pluginId: string): void { + this.stopBuffering(pluginId) + } + + /** + * Replay buffered deltas to a plugin's callback + */ + replayBuffered(pluginId: string, callback: (delta: Delta) => void): void { + const buffered = this.buffers.get(pluginId) || [] + debug(`Replaying ${buffered.length} buffered deltas to ${pluginId}`) + + for (const delta of buffered) { + try { + callback(delta) + } catch (error) { + debug(`Error replaying delta to ${pluginId}:`, error) + } + } + + // Clear buffer after replay + this.buffers.delete(pluginId) + } + + /** + * Get statistics about subscriptions + */ + getStats(): { + totalSubscriptions: number + activePlugins: number + bufferingPlugins: number + bufferedDeltas: number + } { + let totalSubscriptions = 0 + for (const subs of this.subscriptions.values()) { + totalSubscriptions += subs.length + } + + let bufferedDeltas = 0 + for (const buffer of this.buffers.values()) { + bufferedDeltas += buffer.length + } + + return { + totalSubscriptions, + activePlugins: this.subscriptions.size, + bufferingPlugins: this.buffering.size, + bufferedDeltas + } + } + + /** + * Clear all subscriptions and buffers + */ + clear(): void { + this.subscriptions.clear() + this.buffers.clear() + this.buffering.clear() + debug('Cleared all subscriptions and buffers') + } +} + +// Global singleton instance +let subscriptionManager: WasmSubscriptionManager | null = null + +/** + * Get the global subscription manager + */ +export function getSubscriptionManager(): WasmSubscriptionManager { + if (!subscriptionManager) { + subscriptionManager = new WasmSubscriptionManager() + } + return subscriptionManager +} + +/** + * Initialize the subscription manager + */ +export function initializeSubscriptionManager(): WasmSubscriptionManager { + if (subscriptionManager) { + debug('Subscription manager already initialized') + return subscriptionManager + } + + subscriptionManager = new WasmSubscriptionManager() + debug('Subscription manager initialized') + return subscriptionManager +} + +/** + * Reset the subscription manager singleton (for hotplug support) + * This should be called after shutdown to allow re-initialization + */ +export function resetSubscriptionManager(): void { + debug('Resetting subscription manager singleton') + subscriptionManager = null +} diff --git a/test/wasm-plugin-test-config/package.json b/test/wasm-plugin-test-config/package.json new file mode 100644 index 000000000..c7f4792fa --- /dev/null +++ b/test/wasm-plugin-test-config/package.json @@ -0,0 +1,7 @@ +{ + "name": "signalk-server-config", + "version": "0.0.1", + "description": "Test config for WASM plugin tests", + "repository": {}, + "license": "Apache-2.0" +} diff --git a/test/wasm-plugins.ts b/test/wasm-plugins.ts new file mode 100644 index 000000000..a3e635cb6 --- /dev/null +++ b/test/wasm-plugins.ts @@ -0,0 +1,240 @@ +/** + * WASM Plugin Tests + * + * Tests that WASM plugins: + * 1. Can be compiled from source (when SDK is available) + * 2. Are discovered and loaded by the server + * 3. Appear in the plugins API endpoint + * 4. Can be enabled and started + * + * Note: These tests require the example plugin to be pre-built. + * Run from repo root: npm run build:all (which includes WASM examples) + */ + +import { expect } from 'chai' +import fs from 'fs' +import path from 'path' +import { freeport } from './ts-servertestutilities' +import { startServerP } from './servertestutilities' + +interface PluginInfo { + id: string + packageName: string + name: string + version: string + description: string + type: string + data: { + enabled: boolean + } +} + +interface ServerInstance { + stop: () => Promise + app: { + config: { + settings: { + port: number + } + } + } +} + +const wasmTestConfigDirectory = () => + path.join(__dirname, 'wasm-plugin-test-config') + +const examplePluginDir = path.join( + __dirname, + '..', + 'examples', + 'wasm-plugins', + 'example-hello-assemblyscript' +) + +const wasmPath = path.join(examplePluginDir, 'plugin.wasm') + +describe('WASM Plugins', function () { + this.timeout(60000) // WASM compilation and loading can take time + + describe('Build verification', () => { + it('example-hello-assemblyscript WASM file exists', function () { + // Skip if WASM file doesn't exist - it needs to be pre-built + // The SDK is not published to npm, so we can't build in CI without workspace setup + if (!fs.existsSync(wasmPath)) { + this.skip() + return + } + + const stats = fs.statSync(wasmPath) + expect(stats.size).to.be.greaterThan( + 1000, + 'WASM file should be non-trivial size' + ) + }) + }) + + describe('Plugin loading', () => { + let server: ServerInstance | null = null + + before(async function () { + // Skip all loading tests if WASM file doesn't exist + if (!fs.existsSync(wasmPath)) { + this.skip() + return + } + + // Set up the test environment + process.env.SIGNALK_NODE_CONFIG_DIR = wasmTestConfigDirectory() + + // Create symlink to the example plugin in test config node_modules + const pluginDest = path.join( + wasmTestConfigDirectory(), + 'node_modules', + '@signalk', + 'example-hello-assemblyscript' + ) + + // Create @signalk directory if needed + const signalkDir = path.join( + wasmTestConfigDirectory(), + 'node_modules', + '@signalk' + ) + if (!fs.existsSync(signalkDir)) { + fs.mkdirSync(signalkDir, { recursive: true }) + } + + // Remove existing symlink/directory if present + if (fs.existsSync(pluginDest)) { + fs.rmSync(pluginDest, { recursive: true, force: true }) + } + + // Create symlink + fs.symlinkSync(examplePluginDir, pluginDest, 'dir') + }) + + after(async function () { + if (server) { + await server.stop() + } + // Clean up symlink + const pluginDest = path.join( + wasmTestConfigDirectory(), + 'node_modules', + '@signalk', + 'example-hello-assemblyscript' + ) + if (fs.existsSync(pluginDest)) { + fs.rmSync(pluginDest, { recursive: true, force: true }) + } + }) + + it('discovers and registers WASM plugin', async function () { + if (!fs.existsSync(wasmPath)) { + this.skip() + return + } + + const port = await freeport() + + server = await startServerP(port, false, { + settings: { + interfaces: { + plugins: true, + wasm: true + } + } + }) + + // Wait a moment for plugins to fully load + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // Check that the plugin appears in the plugins list + const response = await fetch(`http://0.0.0.0:${port}/skServer/plugins`) + expect(response.status).to.equal(200) + + const plugins: PluginInfo[] = await response.json() + const wasmPlugin = plugins.find( + (p) => + p.id === '_signalk_example-hello-assemblyscript' || + p.packageName === '@signalk/example-hello-assemblyscript' + ) + + expect(wasmPlugin, 'WASM plugin should be in plugins list').to.not.equal( + undefined + ) + expect(wasmPlugin!.type).to.equal( + 'wasm', + 'Plugin should be marked as WASM type' + ) + }) + + it('WASM plugin has correct metadata', async function () { + if (!fs.existsSync(wasmPath) || !server) { + this.skip() + return + } + + // Use the server from the previous test + const port = server.app.config.settings.port + + const response = await fetch(`http://0.0.0.0:${port}/skServer/plugins`) + const plugins: PluginInfo[] = await response.json() + const wasmPlugin = plugins.find( + (p) => + p.id === '_signalk_example-hello-assemblyscript' || + p.packageName === '@signalk/example-hello-assemblyscript' + ) + + expect(wasmPlugin).to.not.equal(undefined) + expect(wasmPlugin!.name).to.be.a('string') + expect(wasmPlugin!.version).to.equal('0.1.0') + expect(wasmPlugin!.description).to.include('Hello World') + }) + + it('WASM plugin can be enabled and started', async function () { + if (!fs.existsSync(wasmPath) || !server) { + this.skip() + return + } + + const port = server.app.config.settings.port + const pluginId = '_signalk_example-hello-assemblyscript' + + // Enable and start the plugin via config endpoint + const configResponse = await fetch( + `http://0.0.0.0:${port}/skServer/plugins/${pluginId}/config`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: true, + configuration: { + message: 'Test message' + } + }) + } + ) + expect(configResponse.status).to.equal(200) + + // Wait for plugin to start + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Check plugin status + const statusResponse = await fetch( + `http://0.0.0.0:${port}/skServer/plugins` + ) + const plugins: PluginInfo[] = await statusResponse.json() + const wasmPlugin = plugins.find((p) => p.id === pluginId) + + expect( + wasmPlugin, + 'Plugin should still exist after enabling' + ).to.not.equal(undefined) + expect(wasmPlugin!.data.enabled).to.equal( + true, + 'Plugin should be enabled' + ) + }) + }) +}) diff --git a/typedoc.json b/typedoc.json index dcb18c59d..d56e8d988 100644 --- a/typedoc.json +++ b/typedoc.json @@ -48,6 +48,7 @@ "excludeInternal": true, "sortEntryPoints": true, "exclude": [ + "packages/assemblyscript-plugin-sdk", "packages/resources-provider-plugin", "packages/server-admin-ui", "packages/server-admin-ui-dependencies", @@ -66,12 +67,19 @@ "darkHighlightTheme": "github-dark-dimmed", "highlightLanguages": [ "bash", - "shell", - "sh", + "csharp", + "go", "json", "javascript", "typescript", - "nginx" + "nginx", + "powershell", + "rust", + "shell", + "sh", + "toml", + "wit", + "xml" ], "cacheBust": true }