Skip to content

Writing Modules

TheMinecraftGuyGuru edited this page Jun 8, 2026 · 5 revisions

Writing Modules

Build your own module — a thread to weave into the MuxCore fabric. This guide walks through creating a sidecar module from nothing: declaring its role, connecting to the gRPC mesh, discovering other modules, and communicating through events.


Before You Start

You need:

  • Go 1.21 or later
  • A GitHub account (to publish the module)
  • Understanding of Core Concepts and the Module System
  • A running MuxCore instance (muxcored) for testing

Module Architecture

Every module is a standalone binary — not compiled into core. Core spawns it as a child process; the module connects to the gRPC mesh and registers at runtime.

my-module/
├── cmd/
│   └── module/
│       └── main.go        ← sidecar entry point (gRPC registration, signal handling)
├── internal/
│   ├── module.go          ← contracts.Module implementation
│   └── ...                ← module-specific logic
├── go.mod                 ← depends on core + contract repos
├── go.sum
└── muxcore.json           ← marketplace metadata

Reference implementation: downloader-qbittorrent.


Step 1: Create the Go Module

mkdir my-module && cd my-module
go mod init github.com/yourname/my-module
go get github.com/Muxcore-Media/core
go get github.com/Muxcore-Media/contracts-downloader  # if building a downloader

Step 2: Implement the Module Interface

Every module implements the contracts.Module lifecycle interface:

package internal

import (
    "context"
    "github.com/Muxcore-Media/core/pkg/contracts"
    dl "github.com/Muxcore-Media/contracts-downloader"
)

type Module struct {
    id       string
    meshAddr string
    conn     *grpc.ClientConn
    // ... module-specific fields
}

func NewModule(moduleID, meshAddr string, conn *grpc.ClientConn) *Module {
    return &Module{id: moduleID, meshAddr: meshAddr, conn: conn}
}

func (m *Module) Info() contracts.ModuleInfo {
    return contracts.ModuleInfo{
        ID:           "downloader-qbittorrent",
        Name:         "qBittorrent Downloader",
        Version:      "1.0.0",
        Roles:        []string{"downloader"},
        Description:  "Bridges MuxCore to qBittorrent via its Web API",
        Author:       "MuxCore",
        Capabilities: []string{"downloader.torrent", "downloader.qbittorrent"},
        Contracts: []contracts.ContractDeclaration{
            {
                Repo:      "github.com/Muxcore-Media/contracts-downloader",
                Version:   "v1.0.0",
                Interface: "Downloader",
            },
        },
        DependsOn: []string{},
    }
}

func (m *Module) Init(ctx context.Context) error {
    // Discover services via DiscoveryService gRPC
    discClient := discoveryv1.NewDiscoveryServiceClient(m.conn)
    resp, _ := discClient.FindByCapability(ctx, &discoveryv1.FindByCapabilityRequest{
        Capability: "storage",
    })
    if len(resp.Modules) > 0 {
        // Storage backend found — set up storage client
    }
    return nil
}

func (m *Module) Start(ctx context.Context) error {
    // Begin operations: start pollers, connect to external services
    return nil
}

func (m *Module) Stop(ctx context.Context) error {
    // Graceful shutdown: stop pollers, close connections
    return nil
}

func (m *Module) Health(ctx context.Context) error {
    // Check connectivity to external dependencies
    return nil
}

Step 3: Write the Sidecar Entry Point

Create cmd/module/main.go — this is the binary that core spawns:

package main

import (
    "context"
    "encoding/json"
    "flag"
    "log/slog"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/yourname/my-module/internal"
    modulev1 "github.com/Muxcore-Media/core/proto/gen/muxcore/module/v1"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

func main() {
    meshAddr := flag.String("muxcore-mesh-addr", "localhost:9090", "gRPC address of the MuxCore mesh")
    moduleID := flag.String("muxcore-module-id", "my-module", "Module identifier")
    flag.Parse()

    // Connect to core mesh with retry
    var conn *grpc.ClientConn
    var err error
    for attempt := 0; attempt < 5; attempt++ {
        conn, err = grpc.NewClient(*meshAddr,
            grpc.WithTransportCredentials(insecure.NewCredentials()),
        )
        if err == nil {
            break
        }
        if attempt < 4 {
            slog.Warn("mesh connection failed, retrying", "attempt", attempt+1, "error", err)
            time.Sleep(time.Duration(attempt+1) * time.Second)
        }
    }
    if err != nil {
        slog.Error("failed to connect to mesh", "error", err)
        os.Exit(1)
    }
    defer conn.Close()

    // Register with core
    regClient := modulev1.NewModuleRegistrationClient(conn)
    mod := internal.NewModule(*moduleID, *meshAddr, conn)
    info := mod.Info()
    infoJSON, _ := json.Marshal(info)

    resp, err := regClient.Register(context.Background(), &modulev1.RegisterRequest{
        ModuleId: *moduleID,
        InfoJson: infoJSON,
        MeshAddr: *meshAddr,
    })
    if err != nil || !resp.Accepted {
        slog.Error("registration failed", "error", err, "reason", resp.Error)
        os.Exit(1)
    }

    // Start the module
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    mod.Init(ctx)
    mod.Start(ctx)

    <-ctx.Done() // Wait for shutdown signal

    mod.Stop(context.Background())
    regClient.Unregister(context.Background(), &modulev1.UnregisterRequest{ModuleId: *moduleID})
}

Every sidecar binary must:

  • Accept --muxcore-mesh-addr (default: localhost:9090)
  • Accept --muxcore-module-id (its identifier)
  • Connect to the gRPC mesh
  • Call ModuleRegistration.Register() with JSON-serialized ModuleInfo
  • Respond to SIGTERM/SIGINT for graceful shutdown
  • Call ModuleRegistration.Unregister() on exit

Step 4: Add Marketplace Metadata

Create muxcore.json in the repo root:

{
  "name": "My Module",
  "description": "Does something useful",
  "version": "1.0.0",
  "author": "Your Name",
  "roles": ["example"],
  "capabilities": ["example.basic"],
  "contracts": [
    {"repo": "github.com/Muxcore-Media/contracts-downloader", "version": "v1.0.0", "interface": "Downloader"}
  ]
}

Discovering Other Modules

Find modules via the DiscoveryService gRPC:

disc := discoveryv1.NewDiscoveryServiceClient(conn)

// By role
resp, _ := disc.FindByRole(ctx, &discoveryv1.FindByRoleRequest{Role: "downloader"})
for _, mod := range resp.Modules {
    // mod.Id, mod.Roles, mod.Capabilities
}

// By capability
resp, _ := disc.FindByCapability(ctx, &discoveryv1.FindByCapabilityRequest{Capability: "secrets"})

// Direct resolve
resp, _ := disc.Resolve(ctx, &discoveryv1.ResolveRequest{ModuleId: "downloader-qbittorrent"})

Communicating Through Events

Publishing Events

import dl "github.com/Muxcore-Media/contracts-downloader"

payload, _ := json.Marshal(dl.DownloadCompletedPayload{
    DownloadID: id,
    Title:      "Movie Title",
})

events := eventsv1.NewEventServiceClient(conn)
events.Publish(ctx, &eventsv1.PublishRequest{
    Event: &eventsv1.Event{
        Type:    dl.EventDownloadCompleted,
        Source:  "my-module",
        Payload: payload,
    },
})

Event types and payloads are defined in the domain contract repos. See Event System for the full event model.


Using Storage

store := storagev1.NewStorageServiceClient(conn)

// Upload (client-streaming)
stream, _ := store.Put(ctx)
stream.Send(&storagev1.PutRequest{Key: "path/to/object", TotalSize: size})
for _, chunk := range chunks {
    stream.Send(&storagev1.PutRequest{Chunk: chunk})
}
resp, _ := stream.CloseAndRecv()

// Download (server-streaming)
getStream, _ := store.Get(ctx, &storagev1.GetRequest{Key: "path/to/object"})
for {
    resp, err := getStream.Recv()
    if err == io.EOF { break }
    // process resp.Chunk
}

// Metadata
stat, _ := store.Stat(ctx, &storagev1.StatRequest{Key: "path/to/object"})
caps, _ := store.Capabilities(ctx, &storagev1.CapabilitiesRequest{})

Calling Other Modules Directly

For request/response patterns, use the ModuleMesh:

mesh := meshv1.NewModuleMeshClient(conn)
resp, _ := mesh.Call(ctx, &meshv1.CallRequest{
    TargetModule: "transcoder-ffmpeg",
    Method:       "Status",
    Payload:      jsonPayload,
})

Building Real Modules

Downloader Module

import dl "github.com/Muxcore-Media/contracts-downloader"

func (m *Module) Add(ctx context.Context, task dl.DownloadTask) (string, error) {
    // task.MagnetURI, task.TorrentURL, task.Meta carry download details
    // Bridge to external download client (qBittorrent, usenet, etc.)
    return downloadID, nil
}

See downloader-qbittorrent for a complete reference implementation — it bridges MuxCore to qBittorrent's Web API, polls torrent state, emits events, and streams files.

Storage Provider Module

func (m *Module) Put(ctx context.Context, key string, data io.Reader, size int64) (int64, error) { ... }
func (m *Module) Get(ctx context.Context, key string) (io.ReadCloser, error) { ... }
func (m *Module) Stat(ctx context.Context, key string) (contracts.ObjectInfo, error) { ... }

// Optional capability interfaces
func (m *Module) Hardlink(ctx context.Context, src, dst string) error { ... }

A storage module registers with role "storage" and announces capabilities like "hardlinkable" and "streamable". Other modules discover it via FindByCapability("storage") and use the StorageService gRPC.


Testing Your Module

Unit tests mock the external services (HTTP servers, gRPC backends). Integration tests need a running muxcored instance.

Unit Tests (Mock HTTP Server)

func TestMyClient(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Return expected API responses
    }))
    defer srv.Close()

    client := NewClient(srv.URL, "user", "pass", 5*time.Second)
    ctx := context.Background()
    client.Login(ctx)
    // ... assertions
}

Integration Tests (Real Core)

# Start core in insecure dev mode
MUXCORE_INSECURE_DISABLE_TLS=true muxcored &

# Build and run module
go build -o my-module ./cmd/module
./my-module --muxcore-mesh-addr localhost:9090 --muxcore-module-id my-module

Docker Integration Testing

# docker-compose.test.yml
services:
  qbittorrent:
    image: linuxserver/qbittorrent:latest
    ports: ["8083:8080"]
    volumes: ["./testdata/downloads:/downloads"]

Module Checklist

Before publishing:

  • go.mod depends on github.com/Muxcore-Media/core plus domain contract repos
  • Sidecar entry point at cmd/module/main.go
  • Accepts --muxcore-mesh-addr and --muxcore-module-id flags
  • Calls ModuleRegistration.Register() at startup
  • Calls ModuleRegistration.Unregister() at shutdown
  • Handles SIGTERM/SIGINT for graceful shutdown
  • Implements contracts.Module (Info, Init, Start, Stop, Health)
  • Implements domain contracts from appropriate contracts-* repos
  • Contracts declared in Info().Contracts
  • muxcore.json at repo root with contracts and capabilities
  • All external services discovered via DiscoveryService.FindByCapability(), never assumed
  • Works gracefully when services are unavailable (degraded state)
  • Meta used for module-specific data instead of assuming core changes

Next Steps

Clone this wiki locally