plugger

package module
v0.0.0-...-99df834 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 23, 2026 License: MIT Imports: 13 Imported by: 0

README

GoDoc GoReportCard Coverage Status

plugger (EXPERIMENTAL)

This is an experimental Go package for creating async JSON via OS pipe based plugins.

Features:

  • Implements asynchronous request-response topology (multiplex)
  • Supports cancelable requests (if the plugin supports it).
  • Uses standard OS pipes (stdout/stderr/stdin), no networking involved.
  • Executes local Go packages (requires the go toolchain to be installed).
  • Executes remote Go modules like github.com/someone/plugin@latest (requires the go toolchain to be installed).
  • Executes arbitrary executable files (shell scripts, binaries, etc.) that implement its JSON protocol (see bash example).
  • No external dependencies 🙌.

Example

$ go run ./cmd/host -p "./cmd/plugin"
PLUG: received request: shared.Request{Question:"u okay?"}
PLUG: received request: shared.Request{Question:"how is it?"}
2025/05/23 19:44:03 ERR: 3: unknown method: wrongmethod
2025/05/23 19:44:03 RESP: 2: shared.Response{Answer:"this is fine"}
2025/05/23 19:44:04 RESP: 1: shared.Response{Answer:"yeah, I'm fine!"}
2025/05/23 19:44:04 DONE

If the plugin is hosted on GitHub you can run it as:

go run ./cmd/host -p github.com/your/plugin@latest

cmd/host/main.go

package main

import (
	"context"
	"errors"
	"flag"
	"io"
	"log"
	"os"
	"sync"

	"pluginexample/shared"

	"github.com/romshark/plugger"
)

func main() {
	fPlugin := flag.String(
		"p", "plugin",
		"path to executable file, a local Go package or a remote Go module",
	)
	flag.Parse()
	if *fPlugin == "" {
		log.Print("please provide a plugin with -p")
		os.Exit(1)
	}
	h := plugger.NewHost()
	ctx := context.Background()
	go func() { // Run the plugin in the background.
		if err := h.RunPlugin(ctx, *fPlugin, os.Stderr); err != nil {
			if !errors.Is(err, io.EOF) {
				log.Fatal(err)
			}
		}
	}()

	// Send three async requests
	var wg sync.WaitGroup
	wg.Add(3)
	go func() { // This request will take 1s to process.
		defer wg.Done()
		request(ctx, h, "1", "hello", "u okay?")
	}()
	go func() { // This request will respond immediately
		defer wg.Done()
		request(ctx, h, "2", "hello", "how is it?")
	}()
	go func() { // This request is intentionally targeting an inexistent endpoint.
		defer wg.Done()
		request(ctx, h, "3", "wrongmethod", "yo")
	}()
	wg.Wait()

	if err := h.Close(); err != nil { // Close stdin pipe shutting the plugin down.
		log.Print("ERR: closing plugin: ", err)
	}
	log.Println("DONE")
}

func request(ctx context.Context, h *plugger.Host, reqPrefix, method, question string) {
	resp, err := plugger.Call[shared.Request, shared.Response](
		ctx, h, method, shared.Request{Question: question},
	)
	if err != nil {
		log.Printf("ERR: %s: %v", reqPrefix, err)
	} else {
		log.Printf("RESP: %s: %#v", reqPrefix, resp)
	}
}

cmd/plugin/main.go

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"pluginexample/shared"

	"github.com/romshark/plugger"
)

func main() {
	p := plugger.NewPlugin()
	plugger.Handle(p, "hello", // Define handler for method "hello".
		func(ctx context.Context, req shared.Request) (shared.Response, error) {
			// Logs must be written to stderr
			// since stdout is reserved for host-plugin communication!
			fmt.Fprintf(os.Stderr, "PLUG: received request: %#v\n", req)
			if req.Question == "u okay?" {
				time.Sleep(time.Second) // Simulate processing...
				if err := ctx.Err(); err != nil {
					return shared.Response{}, err // Request was canceled by host.
				}
				return shared.Response{Answer: "yeah, I'm fine!"}, nil
			}
			return shared.Response{Answer: "this is fine"}, nil
		})
	// Initialization logic goes here before Run.
	os.Exit(p.Run(context.Background()))
}

shared/shared.go

package shared

type Request struct {
	Question string `json:"question"`
}

type Response struct {
	Answer string `json:"answer"`
}

Envelope JSON Schema

Plugger supports any executable that implements the following JSON schema over stdin/stdout:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.com/plugger/envelope.schema.json",
  "title": "Plugger RPC Envelope",
  "description": "Message wrapper exchanged between host and plugin.",
  "oneOf": [
    {
      "$ref": "#/$defs/request"
    },
    {
      "$ref": "#/$defs/response"
    },
    {
      "$ref": "#/$defs/cancel"
    }
  ],
  "$defs": {
    "id": {
      "type": "string",
      "description": "Unique request identifier (hexadecimal number).",
      "pattern": "^[0-9a-fA-F]+$"
    },
    "anyJson": {
      "description": "Arbitrary JSON payload. Returning extra fields the host does not expect is allowed",
      "type": [
        "object",
        "array",
        "string",
        "number",
        "boolean",
        "null"
      ]
    },
    "request": {
      "type": "object",
      "required": [
        "id",
        "method"
      ],
      "properties": {
        "id": {
          "$ref": "#/$defs/id"
        },
        "method": {
          "type": "string",
          "minLength": 1
        },
        "data": {
          "$ref": "#/$defs/anyJson"
        },
        "err": false,
        "cancel": false
      },
      "additionalProperties": false
    },
    "response": {
      "type": "object",
      "required": [
        "id"
      ],
      "properties": {
        "id": {
          "$ref": "#/$defs/id"
        },
        "err": {
          "type": "string"
        },
        "data": {
          "$ref": "#/$defs/anyJson"
        },
        "method": false,
        "cancel": false
      },
      "additionalProperties": false,
      "allOf": [
        {
          "if": {
            "required": [
              "err"
            ]
          },
          "then": {
            "not": {
              "required": [
                "data"
              ]
            }
          }
        }
      ]
    },
    "cancel": {
      "type": "object",
      "required": [
        "cancel"
      ],
      "properties": {
        "cancel": {
          "$ref": "#/$defs/id"
        },
        "id": false,
        "method": false,
        "err": false,
        "data": false
      },
      "additionalProperties": false,
      "description": "Cancellation message; asks the plugin to abort processing of the request whose id equals `cancel`."
    }
  }
}

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrInvalidPluginPath   = errors.New("invalid plugin path")
	ErrAlreadyRunning      = errors.New("plugin already running")
	ErrGoToolchainNotFound = errors.New("go toolchain not in PATH")
	ErrClosed              = errors.New("closed")
	ErrMalformedResponse   = errors.New("malformed response")
)

Functions

func Call

func Call[Req any, Resp any](
	ctx context.Context, h *Host, method string, req Req,
) (Resp, error)

Call sends a typed request and waits for the typed response. Returns ErrMalformedResponse if plugin returns a malformed JSON response. Returns ErrClosed if the plugin is closed.

func Handle

func Handle[Req any, Resp any](
	p *Plugin,
	name string,
	fn func(context.Context, Req) (Resp, error),
)

Handle registers an RPC endpoint overwriting any existing endpoint. Must be used before Run is invoked!

WARNING: Logs must be written to os.Stderr because os.Stdout is reserved for host-plugin communication!

Types

type ErrorResponse

type ErrorResponse string

ErrorResponse is a copy of the "err" field in the plugin response JSON.

func (ErrorResponse) Error

func (e ErrorResponse) Error() string

type Host

type Host struct {
	// contains filtered or unexported fields
}

func NewHost

func NewHost() *Host

NewHost creates an empty host. Call RunPlugin afterwards.

func (*Host) Close

func (h *Host) Close() error

Close closes stdin (signals EOF) and waits for plugin exit. No-op if already closed.

func (*Host) RunPlugin

func (h *Host) RunPlugin(
	ctx context.Context, plugin string, pluginStderr io.WriteCloser,
) error

RunPlugin executes a plugin executable or Go file/package/module.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

func NewPlugin

func NewPlugin() *Plugin

NewPlugin binds to the process’ own stdin/stdout.

func (*Plugin) Run

func (p *Plugin) Run(ctx context.Context) (osReturnCode int)

Run blocks handling requests until stdin closes or ctx is done. Return value is suitable for os.Exit().

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL