diff --git a/plugins/capability_lifecycle.go b/plugins/capability_lifecycle.go new file mode 100644 index 000000000..3f15a41aa --- /dev/null +++ b/plugins/capability_lifecycle.go @@ -0,0 +1,51 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/log" +) + +// CapabilityLifecycle indicates the plugin has lifecycle callback functions. +// Detected when the plugin exports the nd_on_init function. +const CapabilityLifecycle Capability = "Lifecycle" + +const FuncOnInit = "nd_on_init" + +func init() { + registerCapability( + CapabilityLifecycle, + FuncOnInit, + ) +} + +// onInitInput is the input for nd_on_init (currently empty, reserved for future use) +type onInitInput struct{} + +// onInitOutput is the output from nd_on_init +type onInitOutput struct { + Error string `json:"error,omitempty"` +} + +// callPluginInit calls the plugin's nd_on_init function if it has the Lifecycle capability. +// This is called after the plugin is fully loaded with all services registered. +func callPluginInit(ctx context.Context, instance *pluginInstance) { + if !hasCapability(instance.capabilities, CapabilityLifecycle) { + return + } + + log.Debug(ctx, "Calling plugin init function", "plugin", instance.name) + + result, err := callPluginFunction[onInitInput, onInitOutput](ctx, instance, FuncOnInit, onInitInput{}) + if err != nil { + log.Error(ctx, "Plugin init function failed", "plugin", instance.name, err) + return + } + + if result.Error != "" { + log.Error(ctx, "Plugin init function returned error", "plugin", instance.name, "error", result.Error) + return + } + + log.Debug(ctx, "Plugin init function completed", "plugin", instance.name) +} diff --git a/plugins/examples/crypto-ticker/README.md b/plugins/examples/crypto-ticker/README.md new file mode 100644 index 000000000..425ec33b9 --- /dev/null +++ b/plugins/examples/crypto-ticker/README.md @@ -0,0 +1,82 @@ +# Crypto Ticker Plugin + +This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryptocurrency prices from Coinbase. + +## Features + +- Connects to Coinbase WebSocket API to receive real-time ticker updates +- Configurable to track multiple cryptocurrency pairs +- Implements WebSocket callback handlers for message processing +- Automatically reconnects on connection loss using the scheduler service +- Displays price, best bid, best ask, and 24-hour percentage change + +## Configuration + +In your `navidrome.toml` file, add: + +```toml +[PluginConfig.crypto-ticker] +tickers = "BTC,ETH,SOL,MATIC" +``` + +- `tickers` is a comma-separated list of cryptocurrency symbols +- The plugin will append `-USD` to any symbol without a trading pair specified +- Default: `BTC,ETH` if not configured + +## How it Works + +1. On plugin initialization, connects to Coinbase's WebSocket API +2. Subscribes to ticker updates for the configured cryptocurrencies +3. Incoming ticker data is processed via `nd_websocket_on_text_message` callback +4. On connection loss, schedules a reconnection attempt via the scheduler service +5. Reconnection is attempted until successful + +## Building + +This plugin was scaffolded using XTP CLI: + +```bash +xtp plugin init --schema-file ../schemas/websocket_callback.yaml --template go --path ./crypto-ticker --name crypto-ticker +``` + +To build the plugin to WASM: + +```bash +# Using TinyGo (recommended - smaller binary) +tinygo build -o crypto-ticker.wasm -target wasip1 -buildmode=c-shared . + +# Or using standard Go +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o crypto-ticker.wasm . +``` + +## Installation + +Copy the resulting `crypto-ticker.wasm` to your Navidrome plugins folder. + +## Example Output + +``` +[Crypto] Crypto Ticker Plugin initializing... +[Crypto] Configured tickers: [BTC-USD ETH-USD] +[Crypto] Connected to Coinbase WebSocket API (connection: crypto-ticker-conn) +[Crypto] Subscription message sent to Coinbase WebSocket API +[Crypto] Received subscriptions message +[Crypto] 💰 BTC-USD: $98765.43 (24h: +2.35%) Bid: $98764.00 Ask: $98766.00 +[Crypto] 💰 ETH-USD: $3456.78 (24h: -0.54%) Bid: $3455.90 Ask: $3457.80 +``` + +## Permissions Required + +- **config**: Read ticker symbols configuration +- **websocket**: Connect to `ws-feed.exchange.coinbase.com` +- **scheduler**: Schedule reconnection attempts + +## Files + +- `main.go` - Main plugin implementation +- `pdk.gen.go` - Generated WebSocket callback types (from XTP) +- `nd_host.go` - Host function wrappers for WebSocket and Scheduler services + +--- + +For more details, see the source code in `main.go`. diff --git a/plugins/examples/crypto-ticker/go.mod b/plugins/examples/crypto-ticker/go.mod new file mode 100755 index 000000000..ba1d64ce1 --- /dev/null +++ b/plugins/examples/crypto-ticker/go.mod @@ -0,0 +1,5 @@ +module crypto-ticker + +go 1.22.1 + +require github.com/extism/go-pdk v1.1.0 diff --git a/plugins/examples/crypto-ticker/go.sum b/plugins/examples/crypto-ticker/go.sum new file mode 100644 index 000000000..e0fb44c64 --- /dev/null +++ b/plugins/examples/crypto-ticker/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.0 h1:K2On6XOERxrYdsgu0uLzCxeu/FYRHE8jId/hdEVSYoY= +github.com/extism/go-pdk v1.1.0/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/examples/crypto-ticker/main.go b/plugins/examples/crypto-ticker/main.go new file mode 100755 index 000000000..9e07ecefc --- /dev/null +++ b/plugins/examples/crypto-ticker/main.go @@ -0,0 +1,355 @@ +// Crypto Ticker Plugin - Demonstrates WebSocket host service capabilities. +// +// This plugin connects to Coinbase's WebSocket API to receive real-time +// cryptocurrency price updates and logs them to the Navidrome console. +// +// Note: run `go doc -all` in this package to see all of the types and functions available. +// ./pdk.gen.go contains the domain types from the host where your plugin will run. +package main + +import ( + "encoding/json" + "fmt" + "strings" + + pdk "github.com/extism/go-pdk" +) + +const ( + // Coinbase WebSocket API endpoint + coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com" + + // Connection ID for our WebSocket connection + connectionID = "crypto-ticker-conn" + + // ID for the reconnection schedule + reconnectScheduleID = "crypto-ticker-reconnect" +) + +// Manifest types +type Manifest struct { + Name string `json:"name"` + Author string `json:"author"` + Version string `json:"version"` + Description string `json:"description"` + Website string `json:"website,omitempty"` + Permissions *Permissions `json:"permissions,omitempty"` +} + +type Permissions struct { + Config *ConfigPermission `json:"config,omitempty"` + WebSocket *WebSocketPermission `json:"websocket,omitempty"` + Scheduler *SchedulerPermission `json:"scheduler,omitempty"` +} + +type ConfigPermission struct { + Reason string `json:"reason,omitempty"` +} + +type WebSocketPermission struct { + Reason string `json:"reason,omitempty"` + AllowedHosts []string `json:"allowedHosts,omitempty"` +} + +type SchedulerPermission struct { + Reason string `json:"reason,omitempty"` +} + +// Coinbase subscription message structure +type CoinbaseSubscription struct { + Type string `json:"type"` + ProductIDs []string `json:"product_ids"` + Channels []string `json:"channels"` +} + +// Coinbase ticker message structure +type CoinbaseTicker struct { + Type string `json:"type"` + Sequence int64 `json:"sequence"` + ProductID string `json:"product_id"` + Price string `json:"price"` + Open24h string `json:"open_24h"` + Volume24h string `json:"volume_24h"` + Low24h string `json:"low_24h"` + High24h string `json:"high_24h"` + BestBid string `json:"best_bid"` + BestAsk string `json:"best_ask"` + Time string `json:"time"` +} + +// nd_manifest is required by Navidrome to identify the plugin. +// +//export nd_manifest +func ndManifest() int32 { + manifest := Manifest{ + Name: "Crypto Ticker", + Author: "Navidrome", + Version: "1.0.0", + Description: "Real-time cryptocurrency price ticker using Coinbase WebSocket API", + Website: "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker", + Permissions: &Permissions{ + Config: &ConfigPermission{ + Reason: "To read ticker symbols configuration", + }, + WebSocket: &WebSocketPermission{ + Reason: "To connect to Coinbase WebSocket API for real-time prices", + AllowedHosts: []string{"ws-feed.exchange.coinbase.com"}, + }, + Scheduler: &SchedulerPermission{ + Reason: "To schedule reconnection attempts on connection loss", + }, + }, + } + out, err := json.Marshal(manifest) + if err != nil { + pdk.SetError(err) + return 1 + } + pdk.Output(out) + return 0 +} + +// OnInitInput is the input for nd_on_init (currently empty, reserved for future use) +type OnInitInput struct{} + +// OnInitOutput is the output from nd_on_init +type OnInitOutput struct { + Error *string `json:"error,omitempty"` +} + +// nd_on_init is called when the plugin is loaded. +// We use this to establish the initial WebSocket connection. +// +//export nd_on_init +func ndOnInit() int32 { + // Read input (currently empty) + var input OnInitInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...") + + // Get ticker configuration + tickerConfig, ok := pdk.GetConfig("tickers") + if !ok || tickerConfig == "" { + tickerConfig = "BTC,ETH" // Default tickers + } + + tickers := parseTickerSymbols(tickerConfig) + pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers)) + + // Connect to WebSocket + err := connectAndSubscribe(tickers) + if err != nil { + pdk.Log(pdk.LogError, fmt.Sprintf("Failed to connect: %v", err)) + // Don't fail init - let reconnect logic handle it + } + + // Return success output + if err := pdk.OutputJSON(OnInitOutput{}); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +// parseTickerSymbols parses a comma-separated list of ticker symbols +func parseTickerSymbols(tickerConfig string) []string { + parts := strings.Split(tickerConfig, ",") + tickers := make([]string, 0, len(parts)) + for _, ticker := range parts { + ticker = strings.TrimSpace(ticker) + if ticker == "" { + continue + } + // Add -USD suffix if not present + if !strings.Contains(ticker, "-") { + ticker = ticker + "-USD" + } + tickers = append(tickers, ticker) + } + return tickers +} + +// connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers +func connectAndSubscribe(tickers []string) error { + // Connect to WebSocket using host function + connID, err := WebSocketConnect(coinbaseWSEndpoint, nil, connectionID) + if err != nil { + return fmt.Errorf("WebSocket connection error: %v", err) + } + pdk.Log(pdk.LogInfo, fmt.Sprintf("Connected to Coinbase WebSocket API (connection: %s)", connID)) + + // Subscribe to ticker channel + subscription := CoinbaseSubscription{ + Type: "subscribe", + ProductIDs: tickers, + Channels: []string{"ticker"}, + } + + subscriptionJSON, err := json.Marshal(subscription) + if err != nil { + return fmt.Errorf("JSON marshal error: %v", err) + } + + // Send subscription message + err = WebSocketSendText(connectionID, string(subscriptionJSON)) + if err != nil { + return fmt.Errorf("WebSocket send error: %v", err) + } + + pdk.Log(pdk.LogInfo, "Subscription message sent to Coinbase WebSocket API") + return nil +} + +// NdWebsocketOnTextMessage is called when a text message is received +func NdWebsocketOnTextMessage(input OnTextMessageInput) (OnTextMessageOutput, error) { + // Only process messages from our connection + if input.ConnectionId != connectionID { + return OnTextMessageOutput{}, nil + } + + // Try to parse as a ticker message + var ticker CoinbaseTicker + err := json.Unmarshal([]byte(input.Message), &ticker) + if err != nil { + // Not a valid JSON message, ignore + return OnTextMessageOutput{}, nil + } + + // Only process ticker messages + if ticker.Type != "ticker" { + // Could be subscription confirmation or heartbeat + if ticker.Type != "" { + pdk.Log(pdk.LogDebug, fmt.Sprintf("Received %s message", ticker.Type)) + } + return OnTextMessageOutput{}, nil + } + + // Calculate 24h change percentage + change := calculatePercentChange(ticker.Open24h, ticker.Price) + + // Log ticker information + pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s", + ticker.ProductID, + ticker.Price, + change, + ticker.BestBid, + ticker.BestAsk, + )) + + return OnTextMessageOutput{}, nil +} + +// NdWebsocketOnBinaryMessage is called when a binary message is received +func NdWebsocketOnBinaryMessage(input OnBinaryMessageInput) (OnBinaryMessageOutput, error) { + // Coinbase doesn't send binary messages, but we implement the handler anyway + pdk.Log(pdk.LogWarn, fmt.Sprintf("Received unexpected binary message on connection %s", input.ConnectionId)) + return OnBinaryMessageOutput{}, nil +} + +// NdWebsocketOnError is called when an error occurs on the WebSocket connection +func NdWebsocketOnError(input OnErrorInput) (OnErrorOutput, error) { + pdk.Log(pdk.LogError, fmt.Sprintf("WebSocket error on connection %s: %s", input.ConnectionId, input.Error)) + return OnErrorOutput{}, nil +} + +// NdWebsocketOnClose is called when the WebSocket connection is closed +func NdWebsocketOnClose(input OnCloseInput) (OnCloseOutput, error) { + pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection %s closed (code: %d, reason: %s)", + input.ConnectionId, input.Code, input.Reason)) + + // Only attempt reconnect for our connection + if input.ConnectionId == connectionID { + pdk.Log(pdk.LogInfo, "Scheduling reconnection attempt in 5 seconds...") + + // Schedule a one-time reconnection attempt + _, err := SchedulerScheduleOneTime(5, "reconnect", reconnectScheduleID) + if err != nil { + pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err)) + } + } + + return OnCloseOutput{}, nil +} + +// Scheduler callback input/output types +type SchedulerCallbackInput struct { + ScheduleId string `json:"schedule_id"` + Payload string `json:"payload"` + IsRecurring bool `json:"is_recurring"` +} + +type SchedulerCallbackOutput struct { + Error *string `json:"error,omitempty"` +} + +// nd_scheduler_callback is called when a scheduled task fires +// +//export nd_scheduler_callback +func ndSchedulerCallback() int32 { + var input SchedulerCallbackInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + // Only handle our reconnection schedule + if input.ScheduleId != reconnectScheduleID { + return 0 + } + + pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...") + + // Get ticker configuration + tickerConfig, ok := pdk.GetConfig("tickers") + if !ok || tickerConfig == "" { + tickerConfig = "BTC,ETH" + } + + tickers := parseTickerSymbols(tickerConfig) + + // Try to connect and subscribe + err := connectAndSubscribe(tickers) + if err != nil { + pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in 10 seconds", err)) + + // Schedule another attempt + _, err = SchedulerScheduleOneTime(10, "reconnect", reconnectScheduleID) + if err != nil { + pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err)) + } + } else { + pdk.Log(pdk.LogInfo, "Successfully reconnected!") + } + + output := SchedulerCallbackOutput{} + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + return 0 +} + +// calculatePercentChange calculates the percentage change between open and current price +func calculatePercentChange(open, current string) string { + var openFloat, currentFloat float64 + _, err := fmt.Sscanf(open, "%f", &openFloat) + if err != nil || openFloat == 0 { + return "N/A" + } + _, err = fmt.Sscanf(current, "%f", ¤tFloat) + if err != nil { + return "N/A" + } + + change := ((currentFloat - openFloat) / openFloat) * 100 + if change >= 0 { + return fmt.Sprintf("+%.2f", change) + } + return fmt.Sprintf("%.2f", change) +} + +func main() {} diff --git a/plugins/examples/crypto-ticker/nd_host.go b/plugins/examples/crypto-ticker/nd_host.go new file mode 100644 index 000000000..83e33bf68 --- /dev/null +++ b/plugins/examples/crypto-ticker/nd_host.go @@ -0,0 +1,188 @@ +// Host function wrappers for Navidrome plugin services. +// These allow the plugin to call host functions provided by Navidrome. +package main + +import ( + "encoding/json" + "errors" + + pdk "github.com/extism/go-pdk" +) + +// WebSocket host functions + +//go:wasmimport extism:host/user websocket_connect +func websocket_connect(uint64) uint64 + +//go:wasmimport extism:host/user websocket_sendtext +func websocket_sendtext(connectionID uint64, message uint64) uint64 + +//go:wasmimport extism:host/user websocket_closeconnection +func websocket_closeconnection(connectionID uint64, code int32, reason uint64) uint64 + +// WebSocketConnectRequest is the request type for WebSocket.Connect +type WebSocketConnectRequest struct { + Url string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` + ConnectionID string `json:"connectionID,omitempty"` +} + +// WebSocketConnectResponse is the response type for WebSocket.Connect +type WebSocketConnectResponse struct { + NewConnectionID string `json:"newConnectionID,omitempty"` + Error string `json:"error,omitempty"` +} + +// WebSocketConnect establishes a WebSocket connection to the specified URL. +func WebSocketConnect(url string, headers map[string]string, connectionID string) (string, error) { + req := WebSocketConnectRequest{ + Url: url, + Headers: headers, + ConnectionID: connectionID, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return "", err + } + + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + responsePtr := websocket_connect(reqMem.Offset()) + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + var resp WebSocketConnectResponse + if err := json.Unmarshal(responseBytes, &resp); err != nil { + return "", err + } + + if resp.Error != "" { + return "", errors.New(resp.Error) + } + + return resp.NewConnectionID, nil +} + +// WebSocketSendText sends a text message over an established WebSocket connection. +func WebSocketSendText(connectionID, message string) error { + connMem := pdk.AllocateString(connectionID) + defer connMem.Free() + msgMem := pdk.AllocateString(message) + defer msgMem.Free() + + responsePtr := websocket_sendtext(connMem.Offset(), msgMem.Offset()) + if responsePtr != 0 { + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + if errStr != "" { + return errors.New(errStr) + } + } + return nil +} + +// WebSocketCloseConnection gracefully closes a WebSocket connection. +func WebSocketCloseConnection(connectionID string, code int32, reason string) error { + connMem := pdk.AllocateString(connectionID) + defer connMem.Free() + reasonMem := pdk.AllocateString(reason) + defer reasonMem.Free() + + responsePtr := websocket_closeconnection(connMem.Offset(), code, reasonMem.Offset()) + if responsePtr != 0 { + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + if errStr != "" { + return errors.New(errStr) + } + } + return nil +} + +// Scheduler host functions + +//go:wasmimport extism:host/user scheduler_scheduleonetime +func scheduler_scheduleonetime(delaySeconds int32, payload uint64, scheduleID uint64) uint64 + +//go:wasmimport extism:host/user scheduler_schedulerecurring +func scheduler_schedulerecurring(cronExpression uint64, payload uint64, scheduleID uint64) uint64 + +//go:wasmimport extism:host/user scheduler_cancelschedule +func scheduler_cancelschedule(scheduleID uint64) uint64 + +// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime +type SchedulerScheduleOneTimeResponse struct { + NewScheduleID string `json:"newScheduleID,omitempty"` + Error string `json:"error,omitempty"` +} + +// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring +type SchedulerScheduleRecurringResponse struct { + NewScheduleID string `json:"newScheduleID,omitempty"` + Error string `json:"error,omitempty"` +} + +// SchedulerScheduleOneTime schedules a one-time task to run after delaySeconds. +func SchedulerScheduleOneTime(delaySeconds int32, payload, scheduleID string) (string, error) { + payloadMem := pdk.AllocateString(payload) + defer payloadMem.Free() + scheduleIDMem := pdk.AllocateString(scheduleID) + defer scheduleIDMem.Free() + + responsePtr := scheduler_scheduleonetime(delaySeconds, payloadMem.Offset(), scheduleIDMem.Offset()) + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + var resp SchedulerScheduleOneTimeResponse + if err := json.Unmarshal(responseBytes, &resp); err != nil { + return "", err + } + + if resp.Error != "" { + return "", errors.New(resp.Error) + } + + return resp.NewScheduleID, nil +} + +// SchedulerScheduleRecurring schedules a recurring task using a cron expression. +func SchedulerScheduleRecurring(cronExpression, payload, scheduleID string) (string, error) { + cronMem := pdk.AllocateString(cronExpression) + defer cronMem.Free() + payloadMem := pdk.AllocateString(payload) + defer payloadMem.Free() + scheduleIDMem := pdk.AllocateString(scheduleID) + defer scheduleIDMem.Free() + + responsePtr := scheduler_schedulerecurring(cronMem.Offset(), payloadMem.Offset(), scheduleIDMem.Offset()) + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + var resp SchedulerScheduleRecurringResponse + if err := json.Unmarshal(responseBytes, &resp); err != nil { + return "", err + } + + if resp.Error != "" { + return "", errors.New(resp.Error) + } + + return resp.NewScheduleID, nil +} + +// SchedulerCancelSchedule cancels a scheduled task. +func SchedulerCancelSchedule(scheduleID string) error { + scheduleIDMem := pdk.AllocateString(scheduleID) + defer scheduleIDMem.Free() + + responsePtr := scheduler_cancelschedule(scheduleIDMem.Offset()) + if responsePtr != 0 { + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + if errStr != "" { + return errors.New(errStr) + } + } + return nil +} diff --git a/plugins/examples/crypto-ticker/pdk.gen.go b/plugins/examples/crypto-ticker/pdk.gen.go new file mode 100755 index 000000000..028d37c63 --- /dev/null +++ b/plugins/examples/crypto-ticker/pdk.gen.go @@ -0,0 +1,185 @@ +// THIS FILE WAS GENERATED BY `xtp-go-bindgen`. DO NOT EDIT. +package main + +import ( + pdk "github.com/extism/go-pdk" +) + +//export nd_websocket_on_binary_message +func _NdWebsocketOnBinaryMessage() int32 { + var err error + _ = err + pdk.Log(pdk.LogDebug, "NdWebsocketOnBinaryMessage: getting JSON input") + var input OnBinaryMessageInput + err = pdk.InputJSON(&input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnBinaryMessage: calling implementation function") + output, err := NdWebsocketOnBinaryMessage(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnBinaryMessage: setting JSON output") + err = pdk.OutputJSON(output) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnBinaryMessage: returning") + return 0 +} + +//export nd_websocket_on_close +func _NdWebsocketOnClose() int32 { + var err error + _ = err + pdk.Log(pdk.LogDebug, "NdWebsocketOnClose: getting JSON input") + var input OnCloseInput + err = pdk.InputJSON(&input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnClose: calling implementation function") + output, err := NdWebsocketOnClose(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnClose: setting JSON output") + err = pdk.OutputJSON(output) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnClose: returning") + return 0 +} + +//export nd_websocket_on_error +func _NdWebsocketOnError() int32 { + var err error + _ = err + pdk.Log(pdk.LogDebug, "NdWebsocketOnError: getting JSON input") + var input OnErrorInput + err = pdk.InputJSON(&input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnError: calling implementation function") + output, err := NdWebsocketOnError(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnError: setting JSON output") + err = pdk.OutputJSON(output) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnError: returning") + return 0 +} + +//export nd_websocket_on_text_message +func _NdWebsocketOnTextMessage() int32 { + var err error + _ = err + pdk.Log(pdk.LogDebug, "NdWebsocketOnTextMessage: getting JSON input") + var input OnTextMessageInput + err = pdk.InputJSON(&input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnTextMessage: calling implementation function") + output, err := NdWebsocketOnTextMessage(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnTextMessage: setting JSON output") + err = pdk.OutputJSON(output) + if err != nil { + pdk.SetError(err) + return -1 + } + + pdk.Log(pdk.LogDebug, "NdWebsocketOnTextMessage: returning") + return 0 +} + +// Input provided when a binary message is received +type OnBinaryMessageInput struct { + // The unique identifier for the WebSocket connection that received the message. + ConnectionId string `json:"connection_id"` + // The binary data received from the WebSocket, encoded as base64. + Data string `json:"data"` +} + +// Output from the binary message handler +type OnBinaryMessageOutput struct { + // Error message if the callback failed. Empty or null indicates success. + Error *string `json:"error,omitempty"` +} + +// Input provided when a WebSocket connection is closed +type OnCloseInput struct { + // The WebSocket close status code (e.g., 1000 for normal closure, + // 1001 for going away, 1006 for abnormal closure). + Code int32 `json:"code"` + // The unique identifier for the WebSocket connection that was closed. + ConnectionId string `json:"connection_id"` + // The human-readable reason for the connection closure, if provided. + Reason string `json:"reason"` +} + +// Output from the close handler +type OnCloseOutput struct { + // Error message if the callback failed. Empty or null indicates success. + Error *string `json:"error,omitempty"` +} + +// Input provided when an error occurs on a WebSocket connection +type OnErrorInput struct { + // The unique identifier for the WebSocket connection where the error occurred. + ConnectionId string `json:"connection_id"` + // The error message describing what went wrong. + Error string `json:"error"` +} + +// Output from the error handler +type OnErrorOutput struct { + // Error message if the callback failed. Empty or null indicates success. + Error *string `json:"error,omitempty"` +} + +// Input provided when a text message is received +type OnTextMessageInput struct { + // The unique identifier for the WebSocket connection that received the message. + ConnectionId string `json:"connection_id"` + // The text message content received from the WebSocket. + Message string `json:"message"` +} + +// Output from the text message handler +type OnTextMessageOutput struct { + // Error message if the callback failed. Empty or null indicates success. + Error *string `json:"error,omitempty"` +} diff --git a/plugins/manager.go b/plugins/manager.go index 47e966a88..5b8f31a60 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -451,6 +451,10 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { closers: closers, } m.mu.Unlock() + + // Call plugin init function if the plugin has the Lifecycle capability + callPluginInit(m.ctx, m.plugins[name]) + return nil } diff --git a/plugins/schemas/lifecycle.yaml b/plugins/schemas/lifecycle.yaml new file mode 100644 index 000000000..dec4d49ac --- /dev/null +++ b/plugins/schemas/lifecycle.yaml @@ -0,0 +1,41 @@ +version: v1-draft + +exports: + nd_on_init: + description: | + Called after a plugin is fully loaded with all services registered. + Plugins can use this function to perform one-time initialization tasks, + such as establishing connections, starting background processes, or + validating configuration. + + This function is called once when the plugin is loaded, and is NOT + called when the plugin is hot-reloaded. Plugins should not assume + this function will be called on every startup. + + The input is currently empty (reserved for future use). The output + can contain an error string if initialization failed, which will be + logged but will not prevent the plugin from being loaded. + input: + $ref: "#/components/schemas/OnInitInput" + contentType: application/json + output: + $ref: "#/components/schemas/OnInitOutput" + contentType: application/json + +components: + schemas: + OnInitInput: + description: Input provided to the init callback (currently empty, reserved for future use) + type: object + properties: {} + + OnInitOutput: + description: Output from the init callback + type: object + properties: + error: + type: string + nullable: true + description: | + Error message if initialization failed. Empty or null indicates success. + The error is logged but does not prevent the plugin from being loaded. diff --git a/plugins/schemas/websocket_callback.yaml b/plugins/schemas/websocket_callback.yaml index a7b76102e..c21fc2056 100644 --- a/plugins/schemas/websocket_callback.yaml +++ b/plugins/schemas/websocket_callback.yaml @@ -68,7 +68,13 @@ components: OnTextMessageOutput: description: Output from the text message handler - properties: {} + type: object + properties: + error: + type: string + nullable: true + description: | + Error message if the callback failed. Empty or null indicates success. OnBinaryMessageInput: description: Input provided when a binary message is received @@ -88,7 +94,13 @@ components: OnBinaryMessageOutput: description: Output from the binary message handler - properties: {} + type: object + properties: + error: + type: string + nullable: true + description: | + Error message if the callback failed. Empty or null indicates success. OnErrorInput: description: Input provided when an error occurs on a WebSocket connection @@ -107,7 +119,13 @@ components: OnErrorOutput: description: Output from the error handler - properties: {} + type: object + properties: + error: + type: string + nullable: true + description: | + Error message if the callback failed. Empty or null indicates success. OnCloseInput: description: Input provided when a WebSocket connection is closed @@ -133,4 +151,10 @@ components: OnCloseOutput: description: Output from the close handler - properties: {} + type: object + properties: + error: + type: string + nullable: true + description: | + Error message if the callback failed. Empty or null indicates success.