[PR #2178] [MERGED] feat: use yaegi for plugins instead of go-native #9819

Closed
opened 2026-04-23 09:13:55 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/go-vikunja/vikunja/pull/2178
Author: @kolaente
Created: 1/29/2026
Status: Merged
Merged: 3/30/2026
Merged by: @kolaente

Base: mainHead: poc-yaegi-plugins


📝 Commits (7)

  • a64da15 feat(plugins): add plugin system interfaces and manager
  • 2330a2d feat(plugins): add plugin config options
  • a347192 feat(plugins): extract vikunja package symbols for yaegi
  • 92102c8 feat(plugins): extract third-party symbols for yaegi
  • f0a03b4 feat(plugins): add yaegi interpreter-based plugin loader
  • cf633fa feat(plugins): add example plugin
  • 4e28a1e test(plugins): add yaegi plugin integration tests

📊 Changes

22 files changed (+1738 additions, -9 deletions)

View changed files

📝 .golangci.yml (+2 -0)
📝 config-raw.json (+5 -0)
📝 examples/plugins/example/main.go (+13 -1)
📝 go.mod (+1 -0)
📝 go.sum (+2 -0)
📝 pkg/config/config.go (+6 -0)
📝 pkg/initialize/init.go (+1 -0)
📝 pkg/plugins/manager.go (+64 -8)
pkg/plugins/yaegi/events_test.go (+48 -0)
pkg/plugins/yaegi/loader.go (+118 -0)
pkg/plugins/yaegi/loader_test.go (+70 -0)
pkg/plugins/yaegi/routes_test.go (+63 -0)
pkg/yaegi_symbols/echo.go (+285 -0)
pkg/yaegi_symbols/stdlib_check_test.go (+26 -0)
pkg/yaegi_symbols/symbols.go (+8 -0)
pkg/yaegi_symbols/vikunja_db.go (+37 -0)
pkg/yaegi_symbols/vikunja_events.go (+49 -0)
pkg/yaegi_symbols/vikunja_log.go (+40 -0)
pkg/yaegi_symbols/vikunja_models.go (+516 -0)
pkg/yaegi_symbols/vikunja_plugins.go (+118 -0)

...and 2 more files

📄 Description

Summary

Proof-of-concept for a Yaegi-based plugin system as an alternative to Go's native plugin package (.so shared libraries).

Why Yaegi instead of Go's native plugins?

Go's native plugin system (-buildmode=plugin) has significant limitations:

  • Plugins must be compiled with the exact same Go version and identical dependency versions as the host binary
  • Only works on Linux and macOS (no Windows support)
  • No way to unload plugins once loaded
  • Build toolchain constraints make distribution painful

Yaegi is a Go interpreter that loads .go source files at runtime, avoiding all of the above. The trade-off is slower execution (interpreted, not compiled), but for plugin hooks this is generally acceptable.

Key difference: typed factory functions

With native Go plugins, you export a single NewPlugin() plugins.Plugin factory and then type-assert the result to discover additional capabilities:

// Native plugin: single factory, type assertions work
func NewPlugin() plugins.Plugin { return &MyPlugin{} }

// Host code can do:
// p := NewPlugin()
// if rp, ok := p.(plugins.AuthenticatedRouterPlugin); ok { ... }

Yaegi wraps interpreted values per their declared return type, which breaks sub-interface type assertions. So with Yaegi, plugins must export separate typed factory functions for each capability:

// Yaegi plugin: one factory per interface
func NewPlugin() plugins.Plugin                                       { return &MyPlugin{} }
func NewAuthenticatedRouterPlugin() plugins.AuthenticatedRouterPlugin { return &MyPlugin{} }
func NewUnauthenticatedRouterPlugin() plugins.UnauthenticatedRouterPlugin { return &MyPlugin{} }

Only NewPlugin() is required. The others are optional and discovered by the loader.

Plugin capabilities

Capability Interface Description
Lifecycle Plugin Init() / Shutdown() hooks
Authenticated routes AuthenticatedRouterPlugin Routes under /api/v1/plugins/* (behind auth)
Unauthenticated routes UnauthenticatedRouterPlugin Routes under /plugins/* (no auth, rate-limited)
Database migrations MigrationPlugin xormigrate migrations appended to core
Event listeners Via events.RegisterListener() in Init() React to domain events (e.g. TaskCreatedEvent)

Plugins also have access to Vikunja internals: db.NewSession(), user.GetCurrentUserFromDB(), log.Infof(), all model types, etc.

Writing a plugin

See examples/plugins/example/main.go for a complete example. The key points:

  1. Package must be main
  2. Define a struct implementing plugins.Plugin (required) plus any optional interfaces
  3. Export typed factory functions (see above)
  4. In Init(), register event listeners via events.RegisterListener()
  5. In route registration methods, add Echo handlers to the provided group

Configuration

plugins:
  enabled: true
  dir: ./plugins    # directory containing plugin source folders or .so files
  loader: native    # "native" (default) for .so files, "yaegi" for Go source dirs

The plugins.loader value is validated at startup — invalid values cause a fatal error.

  • native (default): scans plugins.dir for .so shared library files. Plugins must be compiled with the exact same Go version and dependencies as the Vikunja binary.
  • yaegi: scans plugins.dir for subdirectories containing .go source files. Plugins are interpreted at runtime — no compilation step needed.

Known limitations

  • Single-file plugins only (for now): The yaegi loader evaluates .go files individually via Eval() rather than using yaegi's EvalPath(). This means multi-file plugins may hit order-dependency issues if cross-file declarations depend on filename ordering. EvalPath() would solve this, but it doesn't support main.NewPlugin symbol lookup for package main — yaegi resolves the package name from the import path rather than the package declaration. A future fix could either use a non-main package convention or find a workaround for EvalPath symbol resolution.
  • Typed factory functions required: Due to yaegi's interface wrapping, plugins cannot use a single factory with type assertions. Each capability needs its own exported factory function (see above).
  • Interpreted performance: Yaegi-loaded plugins run interpreted, not compiled. This is fine for hooks and route handlers but would be a concern for hot-path code.

Files

  • pkg/plugins/interfaces.go — plugin interface definitions
  • pkg/plugins/manager.go — plugin manager, loads plugins via configured loader
  • pkg/plugins/yaegi/loader.go — Yaegi-based interpreter loader
  • pkg/yaegi_symbols/ — generated symbol tables exposing Vikunja internals to interpreted code
  • examples/plugins/example/main.go — example plugin

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/go-vikunja/vikunja/pull/2178 **Author:** [@kolaente](https://github.com/kolaente) **Created:** 1/29/2026 **Status:** ✅ Merged **Merged:** 3/30/2026 **Merged by:** [@kolaente](https://github.com/kolaente) **Base:** `main` ← **Head:** `poc-yaegi-plugins` --- ### 📝 Commits (7) - [`a64da15`](https://github.com/go-vikunja/vikunja/commit/a64da159cbdcd780f86827b91a002bd37937c7bf) feat(plugins): add plugin system interfaces and manager - [`2330a2d`](https://github.com/go-vikunja/vikunja/commit/2330a2db9839a89db14e57f7e67bfde925eab0ac) feat(plugins): add plugin config options - [`a347192`](https://github.com/go-vikunja/vikunja/commit/a347192beca77cd79cb3f97e982a56e31460dbe9) feat(plugins): extract vikunja package symbols for yaegi - [`92102c8`](https://github.com/go-vikunja/vikunja/commit/92102c8cf93a56917f7c21d0ba4222e3dc630f73) feat(plugins): extract third-party symbols for yaegi - [`f0a03b4`](https://github.com/go-vikunja/vikunja/commit/f0a03b4bc03c20746bcdada64abd53cde5e642d1) feat(plugins): add yaegi interpreter-based plugin loader - [`cf633fa`](https://github.com/go-vikunja/vikunja/commit/cf633fada79b1d447d0eb4485673a797ddddabc1) feat(plugins): add example plugin - [`4e28a1e`](https://github.com/go-vikunja/vikunja/commit/4e28a1eee5a3659e7a2141fd0b2b1bd4783343c7) test(plugins): add yaegi plugin integration tests ### 📊 Changes **22 files changed** (+1738 additions, -9 deletions) <details> <summary>View changed files</summary> 📝 `.golangci.yml` (+2 -0) 📝 `config-raw.json` (+5 -0) 📝 `examples/plugins/example/main.go` (+13 -1) 📝 `go.mod` (+1 -0) 📝 `go.sum` (+2 -0) 📝 `pkg/config/config.go` (+6 -0) 📝 `pkg/initialize/init.go` (+1 -0) 📝 `pkg/plugins/manager.go` (+64 -8) ➕ `pkg/plugins/yaegi/events_test.go` (+48 -0) ➕ `pkg/plugins/yaegi/loader.go` (+118 -0) ➕ `pkg/plugins/yaegi/loader_test.go` (+70 -0) ➕ `pkg/plugins/yaegi/routes_test.go` (+63 -0) ➕ `pkg/yaegi_symbols/echo.go` (+285 -0) ➕ `pkg/yaegi_symbols/stdlib_check_test.go` (+26 -0) ➕ `pkg/yaegi_symbols/symbols.go` (+8 -0) ➕ `pkg/yaegi_symbols/vikunja_db.go` (+37 -0) ➕ `pkg/yaegi_symbols/vikunja_events.go` (+49 -0) ➕ `pkg/yaegi_symbols/vikunja_log.go` (+40 -0) ➕ `pkg/yaegi_symbols/vikunja_models.go` (+516 -0) ➕ `pkg/yaegi_symbols/vikunja_plugins.go` (+118 -0) _...and 2 more files_ </details> ### 📄 Description ## Summary Proof-of-concept for a Yaegi-based plugin system as an alternative to Go's native `plugin` package (`.so` shared libraries). ### Why Yaegi instead of Go's native plugins? Go's native plugin system (`-buildmode=plugin`) has significant limitations: - Plugins must be compiled with the **exact same Go version** and **identical dependency versions** as the host binary - Only works on Linux and macOS (no Windows support) - No way to unload plugins once loaded - Build toolchain constraints make distribution painful [Yaegi](https://github.com/traefik/yaegi) is a Go interpreter that loads `.go` source files at runtime, avoiding all of the above. The trade-off is slower execution (interpreted, not compiled), but for plugin hooks this is generally acceptable. ### Key difference: typed factory functions With native Go plugins, you export a single `NewPlugin() plugins.Plugin` factory and then type-assert the result to discover additional capabilities: ```go // Native plugin: single factory, type assertions work func NewPlugin() plugins.Plugin { return &MyPlugin{} } // Host code can do: // p := NewPlugin() // if rp, ok := p.(plugins.AuthenticatedRouterPlugin); ok { ... } ``` **Yaegi wraps interpreted values per their declared return type**, which breaks sub-interface type assertions. So with Yaegi, plugins must export **separate typed factory functions** for each capability: ```go // Yaegi plugin: one factory per interface func NewPlugin() plugins.Plugin { return &MyPlugin{} } func NewAuthenticatedRouterPlugin() plugins.AuthenticatedRouterPlugin { return &MyPlugin{} } func NewUnauthenticatedRouterPlugin() plugins.UnauthenticatedRouterPlugin { return &MyPlugin{} } ``` Only `NewPlugin()` is required. The others are optional and discovered by the loader. ### Plugin capabilities | Capability | Interface | Description | |---|---|---| | Lifecycle | `Plugin` | `Init()` / `Shutdown()` hooks | | Authenticated routes | `AuthenticatedRouterPlugin` | Routes under `/api/v1/plugins/*` (behind auth) | | Unauthenticated routes | `UnauthenticatedRouterPlugin` | Routes under `/plugins/*` (no auth, rate-limited) | | Database migrations | `MigrationPlugin` | xormigrate migrations appended to core | | Event listeners | Via `events.RegisterListener()` in `Init()` | React to domain events (e.g. `TaskCreatedEvent`) | Plugins also have access to Vikunja internals: `db.NewSession()`, `user.GetCurrentUserFromDB()`, `log.Infof()`, all model types, etc. ### Writing a plugin See `examples/plugins/example/main.go` for a complete example. The key points: 1. Package must be `main` 2. Define a struct implementing `plugins.Plugin` (required) plus any optional interfaces 3. Export typed factory functions (see above) 4. In `Init()`, register event listeners via `events.RegisterListener()` 5. In route registration methods, add Echo handlers to the provided group ### Configuration ```yaml plugins: enabled: true dir: ./plugins # directory containing plugin source folders or .so files loader: native # "native" (default) for .so files, "yaegi" for Go source dirs ``` The `plugins.loader` value is validated at startup — invalid values cause a fatal error. - **`native`** (default): scans `plugins.dir` for `.so` shared library files. Plugins must be compiled with the exact same Go version and dependencies as the Vikunja binary. - **`yaegi`**: scans `plugins.dir` for subdirectories containing `.go` source files. Plugins are interpreted at runtime — no compilation step needed. ### Known limitations - **Single-file plugins only (for now):** The yaegi loader evaluates `.go` files individually via `Eval()` rather than using yaegi's `EvalPath()`. This means multi-file plugins may hit order-dependency issues if cross-file declarations depend on filename ordering. `EvalPath()` would solve this, but it doesn't support `main.NewPlugin` symbol lookup for `package main` — yaegi resolves the package name from the import path rather than the `package` declaration. A future fix could either use a non-main package convention or find a workaround for `EvalPath` symbol resolution. - **Typed factory functions required:** Due to yaegi's interface wrapping, plugins cannot use a single factory with type assertions. Each capability needs its own exported factory function (see above). - **Interpreted performance:** Yaegi-loaded plugins run interpreted, not compiled. This is fine for hooks and route handlers but would be a concern for hot-path code. ### Files - `pkg/plugins/interfaces.go` — plugin interface definitions - `pkg/plugins/manager.go` — plugin manager, loads plugins via configured loader - `pkg/plugins/yaegi/loader.go` — Yaegi-based interpreter loader - `pkg/yaegi_symbols/` — generated symbol tables exposing Vikunja internals to interpreted code - `examples/plugins/example/main.go` — example plugin --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-04-23 09:13:55 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/vikunja#9819