-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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
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.
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 downloaderEvery 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
}Create cmd/module/main.go — this is the binary that core spawns:
package main
import (
"context"
"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")
tlsCert := flag.String("muxcore-tls-cert", "", "Path to TLS client certificate (auto-provided by core)")
tlsKey := flag.String("muxcore-tls-key", "", "Path to TLS client private key (auto-provided by core)")
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()
resp, err := regClient.Register(context.Background(), &modulev1.RegisterRequest{
ModuleId: *moduleID,
ModuleInfo: &modulev1.ModuleInfo{
Id: info.ID,
Name: info.Name,
Version: info.Version,
Roles: info.Roles,
Description: info.Description,
Author: info.Author,
Capabilities: info.Capabilities,
DependsOn: info.DependsOn,
MinCoreVersion: info.MinCoreVersion,
HttpAddr: info.HTTPAddr,
},
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) - Accept
--muxcore-tls-certand--muxcore-tls-key(auto-provided by core when spawned via--tag) - Connect to the gRPC mesh
- Call
ModuleRegistration.Register()with structuredModuleInfo - Respond to SIGTERM/SIGINT for graceful shutdown
- Call
ModuleRegistration.Unregister()on exit
If your module runs outside core's sidecar manager (separate container, different machine), you cannot rely on auto-issued TLS certificates. Instead, use the BootstrapRegister RPC with a one-time token:
- Generate a bootstrap token for your module on the core node
- Pass the token to your module (via env var, config file, or CLI flag)
- Your module calls
BootstrapRegisterto receive a signed certificate - Your module reconnects with mTLS using the received credentials
regClient := modulev1.NewModuleRegistrationClient(conn)
// Exchange bootstrap token for a signed certificate
bootstrapResp, err := regClient.BootstrapRegister(ctx, &modulev1.BootstrapRegisterRequest{
Token: *bootstrapToken,
ModuleId: *moduleID,
})
if err != nil || !bootstrapResp.Accepted {
slog.Error("bootstrap registration failed", "error", err)
os.Exit(1)
}
// Use the returned credentials for subsequent connections
certPEM := bootstrapResp.SignedCert
keyPEM := bootstrapResp.KeyPem
caPEM := bootstrapResp.CaCertThe token is single-use, expires after 5 minutes, and is bound to a specific module ID. See Module TLS Authentication for the full architecture.
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"}
]
}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"})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.
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{})For request/response patterns, use the ModuleMesh:
mesh := meshv1.NewModuleMeshClient(conn)
resp, _ := mesh.Call(ctx, &meshv1.CallRequest{
TargetModule: "transcoder-ffmpeg",
Method: "Status",
Payload: jsonPayload,
})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.
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.
Media modules (movies, TV, books) can expose browsing, search, metadata editing, and artwork management to the admin UI by implementing the MediaAdminService gRPC service.
import (
mediaadminv1 "github.com/Muxcore-Media/contracts-media-admin/gen/muxcore/media/admin/v1"
)type mediaAdminServer struct {
mediaadminv1.UnimplementedMediaAdminServer
// Your media module state
}
func (s *mediaAdminServer) GetMediaTypeInfo(ctx context.Context, req *mediaadminv1.GetMediaTypeInfoRequest) (*mediaadminv1.GetMediaTypeInfoResponse, error) {
return &mediaadminv1.GetMediaTypeInfoResponse{
DisplayName: "Movies", // Shows as nav tab label
FilterFields: []*mediaadminv1.FilterField{
{Key: "year", Label: "Year", Type: "number"},
{Key: "genre", Label: "Genre", Type: "select", Options: []string{"Action", "Comedy", "Drama"}},
},
}, nil
}
func (s *mediaAdminServer) ListItems(ctx context.Context, req *mediaadminv1.ListItemsRequest) (*mediaadminv1.ListItemsResponse, error) {
// Query your database (via DatabaseProvider) and return items
items := []*mediaadminv1.MediaItem{
{
Id: "123", Title: "The Terminator", Year: 1984,
Genres: []string{"Action", "Sci-Fi"},
Metadata: map[string]string{"director": "James Cameron", "runtime": "108"},
},
}
return &mediaadminv1.ListItemsResponse{Items: items, Total: 1, Page: 1}, nil
}
func (s *mediaAdminServer) GetItem(ctx context.Context, req *mediaadminv1.GetItemRequest) (*mediaadminv1.GetItemResponse, error) {
// Return single item with full detail
}
func (s *mediaAdminServer) UpdateMetadata(ctx context.Context, req *mediaadminv1.UpdateMetadataRequest) (*mediaadminv1.UpdateMetadataResponse, error) {
// Update item metadata in your database
}
func (s *mediaAdminServer) ListArtwork(ctx context.Context, req *mediaadminv1.ListArtworkRequest) (*mediaadminv1.ListArtworkResponse, error) {
// Return artwork URLs for the item
}
func (s *mediaAdminServer) ReplaceArtwork(stream mediaadminv1.MediaAdminService_ReplaceArtworkServer) error {
// Receive artwork image chunks and store
}grpcSrv := grpc.NewServer()
mediaadminv1.RegisterMediaAdminServiceServer(grpcSrv, &mediaAdminServer{})
// Also register any other services your module providesinfo := contracts.ModuleInfo{
ID: "media-movies",
Name: "Movies",
Description: "Movie library management",
Roles: []string{"media_manager"},
Capabilities: []string{"media.library"},
HttpAddr: ":9410", // gRPC port MediaAdminService listens on
}Use the central DatabaseProvider for persistence. Each media module runs its own migrations (Migrate()) to create its tables within the shared database. The metadata map<string,string> field on MediaItem carries type-specific fields (runtime for movies, episode count for TV, page count for books) — this is the Meta pattern. The admin UI renders them generically.
Unit tests mock the external services (HTTP servers, gRPC backends). Integration tests need a running muxcored instance.
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
}# 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-compose.test.yml
services:
qbittorrent:
image: linuxserver/qbittorrent:latest
ports: ["8083:8080"]
volumes: ["./testdata/downloads:/downloads"]Before publishing:
-
go.moddepends ongithub.com/Muxcore-Media/coreplus domain contract repos - Sidecar entry point at
cmd/module/main.go - Accepts
--muxcore-mesh-addr,--muxcore-module-id,--muxcore-tls-cert, and--muxcore-tls-keyflags - Calls
ModuleRegistration.Register()at startup with structuredModuleInfo - 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 -
Contractsdeclared inInfo().Contracts -
muxcore.jsonat repo root withcontractsandcapabilities - All external services discovered via
DiscoveryService.FindByCapability(), never assumed - Works gracefully when services are unavailable (degraded state)
-
Metaused for module-specific data instead of assuming core changes
- Contracts Reference — every interface in detail
- Module System — all module types and discovery
- Event System — how modules communicate through events
- downloader-qbittorrent — complete reference implementation