go/docs/pkg/core.md
Snider 915816b3b5 docs: add pkg/core documentation, remove 12MB stale generated site
- Add comprehensive docs/pkg/core.md covering DI container, service
  pattern, message bus (ACTION/QUERY/TASK), error handling, runtime
- Remove pkg/core/docs/site/ (ancient MkDocs HTML with Lethean branding)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-06 14:29:04 +00:00

610 lines
18 KiB
Markdown

# pkg/core -- Dependency Injection & Service Framework
`pkg/core` is the foundation of the Core application framework. It provides a dependency injection container, service lifecycle management, and a message bus for inter-service communication. Every other package in the ecosystem builds on top of it.
The package is designed for use with Wails v3 (desktop GUI) but is equally useful in CLI and headless applications.
---
## Core Struct
`Core` is the central application object. It owns the service registry, the message bus, embedded assets, and feature flags.
```go
type Core struct {
App any // GUI runtime (e.g. Wails App), set by WithApp
Features *Features // Feature flags
// unexported: svc *serviceManager, bus *messageBus, assets embed.FS
}
```
### Creating a Core Instance
`New()` is the sole constructor. It accepts a variadic list of `Option` functions that configure the instance before it is returned. After all options are applied, the service lock is finalised.
```go
c, err := core.New(
core.WithService(mypackage.NewMyService),
core.WithAssets(embeddedFS),
core.WithServiceLock(),
)
```
If any option returns an error, `New()` returns `nil` and that error immediately.
### Options
| Option | Purpose |
|--------|---------|
| `WithService(factory)` | Register a service via factory function. Auto-discovers the service name from the factory's return type package path and auto-registers an IPC handler if the service has a `HandleIPCEvents` method. |
| `WithName(name, factory)` | Register a service with an explicit name. Does **not** auto-discover IPC handlers. |
| `WithApp(app)` | Inject a GUI runtime (e.g. Wails `*application.App`) into `Core.App`. |
| `WithAssets(fs)` | Attach an `embed.FS` containing frontend assets. |
| `WithServiceLock()` | Prevent any further service registration after `New()` completes. Calls to `RegisterService` after the lock is applied return an error. |
The `Option` type is defined as:
```go
type Option func(*Core) error
```
### Service Retrieval
Services are retrieved by name. Two generic helpers provide type-safe access:
```go
// Returns (T, error) -- safe version
svc, err := core.ServiceFor[*MyService](c, "myservice")
// Panics if not found or wrong type -- use in init paths
svc := core.MustServiceFor[*MyService](c, "myservice")
```
The untyped `Service(name)` method returns `any` (or `nil` if not found).
### Convenience Accessors
`Core` provides shorthand methods for well-known services:
```go
c.Config() // returns Config interface
c.Display() // returns Display interface
c.Workspace() // returns Workspace interface
c.Crypt() // returns Crypt interface
```
Each calls `MustServiceFor` internally and will panic if the named service is not registered.
### Global Instance
For GUI runtimes that require global access, a singleton pattern is available:
```go
core.SetInstance(c) // store globally (thread-safe)
app := core.App() // retrieve Core.App (panics if not set)
inst := core.GetInstance() // retrieve *Core (returns nil if not set)
core.ClearInstance() // reset to nil (primarily for tests)
```
### Feature Flags
The `Features` struct holds a simple string slice of enabled flags:
```go
c.Features.Flags = []string{"dark-mode", "beta-api"}
c.Features.IsEnabled("dark-mode") // true
```
---
## Service Pattern
### Factory Functions
Services are created via factory functions that receive the `*Core` and return `(any, error)`:
```go
func NewMyService(c *core.Core) (any, error) {
return &MyService{
ServiceRuntime: core.NewServiceRuntime(c, MyOptions{BufferSize: 64}),
}, nil
}
```
The factory is called during `New()` when the corresponding `WithService` or `WithName` option is processed.
### ServiceRuntime[T]
`ServiceRuntime[T]` is a generic helper struct that services embed to gain access to the `Core` instance and typed options:
```go
type ServiceRuntime[T any] struct {
core *core.Core
opts T
}
```
Constructor:
```go
rt := core.NewServiceRuntime[MyOptions](c, MyOptions{BufferSize: 64})
```
Methods:
| Method | Returns |
|--------|---------|
| `Core()` | `*Core` -- the parent container |
| `Opts()` | `T` -- the service's typed options |
| `Config()` | `Config` -- shorthand for `Core().Config()` |
Example service:
```go
type MyService struct {
*core.ServiceRuntime[MyOptions]
items map[string]string
}
type MyOptions struct {
BufferSize int
}
func NewMyService(c *core.Core) (any, error) {
return &MyService{
ServiceRuntime: core.NewServiceRuntime(c, MyOptions{BufferSize: 128}),
items: make(map[string]string),
}, nil
}
```
### Startable and Stoppable Interfaces
Services that need lifecycle hooks implement one or both of:
```go
type Startable interface {
OnStartup(ctx context.Context) error
}
type Stoppable interface {
OnShutdown(ctx context.Context) error
}
```
The service manager detects these interfaces at registration time and stores references internally.
- **Startup**: `ServiceStartup()` calls `OnStartup` on every `Startable` service in registration order, then broadcasts `ActionServiceStartup{}` via the message bus.
- **Shutdown**: `ServiceShutdown()` first broadcasts `ActionServiceShutdown{}`, then calls `OnShutdown` on every `Stoppable` service in **reverse** registration order. This ensures that services which were started last are stopped first, respecting dependency order.
Errors from individual services are aggregated via `errors.Join` and returned together, so one failing service does not prevent others from completing their lifecycle.
### Service Lock
When `WithServiceLock()` is passed to `New()`, the `serviceManager` sets `lockEnabled = true` during option processing. After all options have been applied, `applyLock()` sets `locked = true`. Any subsequent call to `RegisterService` returns an error:
```
core: service "late-service" is not permitted by the serviceLock setting
```
This prevents accidental late-binding of services after the application has been fully wired.
### Service Name Discovery
`WithService` derives the service name from the Go package path of the returned struct. For a type `myapp/services.Calculator`, the name becomes `services`. For `myapp/calculator.Service`, it becomes `calculator`.
To control the name explicitly, use `WithName("calc", factory)`.
### IPC Handler Discovery
`WithService` also checks whether the service has a method named `HandleIPCEvents` with signature `func(*Core, Message) error`. If found, it is automatically registered as an ACTION handler via `RegisterAction`.
`WithName` does **not** perform this discovery. Register handlers manually if needed.
---
## Message Bus
The message bus provides three distinct communication patterns, all thread-safe:
### 1. ACTION -- Fire-and-Forget Broadcast
`ACTION` dispatches a message to **all** registered handlers. Every handler is called; errors are aggregated.
```go
// Define a message type
type OrderPlaced struct {
OrderID string
Total float64
}
// Dispatch
err := c.ACTION(OrderPlaced{OrderID: "abc", Total: 42.50})
// Register a handler
c.RegisterAction(func(c *core.Core, msg core.Message) error {
switch m := msg.(type) {
case OrderPlaced:
log.Printf("Order %s placed for %.2f", m.OrderID, m.Total)
}
return nil
})
```
Multiple handlers can be registered at once with `RegisterActions(h1, h2, h3)`.
The `Message` type is defined as `any`, so any struct can serve as a message. Handlers use a type switch to filter messages they care about.
**Built-in action messages:**
| Message | Broadcast when |
|---------|---------------|
| `ActionServiceStartup{}` | After all `Startable.OnStartup` calls complete |
| `ActionServiceShutdown{}` | Before `Stoppable.OnShutdown` calls begin |
| `ActionTaskStarted{TaskID, Task}` | A `PerformAsync` task begins |
| `ActionTaskProgress{TaskID, Task, Progress, Message}` | A background task reports progress |
| `ActionTaskCompleted{TaskID, Task, Result, Error}` | A `PerformAsync` task finishes |
### 2. QUERY -- Read-Only Request/Response
`QUERY` dispatches a query to handlers until the **first** one responds (returns `handled = true`). Remaining handlers are skipped.
```go
type GetUserByID struct {
ID string
}
// Register
c.RegisterQuery(func(c *core.Core, q core.Query) (any, bool, error) {
switch req := q.(type) {
case GetUserByID:
user, err := db.Find(req.ID)
return user, true, err
}
return nil, false, nil // not handled -- pass to next handler
})
// Dispatch
result, handled, err := c.QUERY(GetUserByID{ID: "u-123"})
if !handled {
// no handler recognised this query
}
user := result.(*User)
```
`QUERYALL` dispatches the query to **all** handlers and collects every non-nil result:
```go
results, err := c.QUERYALL(ListPlugins{})
// results is []any containing responses from every handler that responded
```
The `Query` type is `any`. The `QueryHandler` signature is:
```go
type QueryHandler func(*Core, Query) (any, bool, error)
```
### 3. TASK -- Side-Effect Request/Response
`PERFORM` dispatches a task to handlers until the **first** one executes it (returns `handled = true`). Semantically identical to `QUERY` but intended for operations with side effects.
```go
type SendEmail struct {
To string
Subject string
Body string
}
c.RegisterTask(func(c *core.Core, t core.Task) (any, bool, error) {
switch task := t.(type) {
case SendEmail:
err := mailer.Send(task.To, task.Subject, task.Body)
return nil, true, err
}
return nil, false, nil
})
result, handled, err := c.PERFORM(SendEmail{
To: "user@example.com",
Subject: "Welcome",
Body: "Hello!",
})
```
The `Task` type is `any`. The `TaskHandler` signature is:
```go
type TaskHandler func(*Core, Task) (any, bool, error)
```
### Background Tasks
`PerformAsync` runs a `PERFORM` dispatch in a background goroutine and returns a task ID immediately:
```go
taskID := c.PerformAsync(BuildProject{Path: "/src"})
// taskID is "task-1", "task-2", etc.
```
The framework automatically broadcasts:
1. `ActionTaskStarted` -- when the goroutine begins
2. `ActionTaskCompleted` -- when the task finishes (includes `Result` and `Error`)
If the task implements `TaskWithID`, the framework injects the assigned ID before execution:
```go
type TaskWithID interface {
Task
SetTaskID(id string)
GetTaskID() string
}
```
Services can report progress during long-running tasks:
```go
c.Progress(taskID, 0.5, "Compiling 50%...", task)
// Broadcasts ActionTaskProgress{TaskID: taskID, Progress: 0.5, Message: "..."}
```
### Thread Safety
The message bus uses `sync.RWMutex` for each handler slice (IPC, query, task). Handler registration acquires a write lock; dispatch acquires a read lock and copies the handler slice before iterating, so dispatches never block registrations.
---
## Error Handling
The `Error` struct provides contextual error wrapping:
```go
type Error struct {
Op string // operation, e.g. "config.Load"
Msg string // human-readable description
Err error // underlying error (optional)
}
```
### E() Helper
`E()` is the primary constructor:
```go
return core.E("config.Load", "failed to read config file", err)
// Output: "config.Load: failed to read config file: <underlying error>"
return core.E("auth.Login", "invalid credentials", nil)
// Output: "auth.Login: invalid credentials"
```
When `err` is `nil`, the resulting `Error` has no wrapped cause.
### Error Chain Compatibility
`Error` implements `Unwrap()`, so it works with `errors.Is()` and `errors.As()`:
```go
var coreErr *core.Error
if errors.As(err, &coreErr) {
log.Printf("Operation: %s, Message: %s", coreErr.Op, coreErr.Msg)
}
```
### Convention
The `Op` field should follow `package.Function` or `service.Method` format. The `Msg` field should be a human-readable sentence suitable for display to end users.
---
## Runtime (Wails Integration)
The `Runtime` struct wraps `Core` for use as a Wails service. It implements the Wails service interface (`ServiceName`, `ServiceStartup`, `ServiceShutdown`).
```go
type Runtime struct {
app any // GUI runtime
Core *Core
}
```
### NewRuntime
Creates a minimal runtime with no custom services:
```go
rt, err := core.NewRuntime(wailsApp)
```
### NewWithFactories
Creates a runtime with named service factories. Factories are called in sorted (alphabetical) order to ensure deterministic initialisation:
```go
rt, err := core.NewWithFactories(wailsApp, map[string]core.ServiceFactory{
"calculator": func() (any, error) { return &Calculator{}, nil },
"logger": func() (any, error) { return &Logger{}, nil },
})
```
`ServiceFactory` is defined as `func() (any, error)` -- note it does **not** receive `*Core`, unlike the `WithService` factory. The `Runtime` wraps each factory result with `WithName` internally.
### Lifecycle Delegation
`Runtime.ServiceStartup` and `Runtime.ServiceShutdown` delegate directly to `Core.ServiceStartup` and `Core.ServiceShutdown`. The Wails runtime calls these automatically.
```go
func (r *Runtime) ServiceStartup(ctx context.Context, options any) {
_ = r.Core.ServiceStartup(ctx, options)
}
func (r *Runtime) ServiceShutdown(ctx context.Context) {
if r.Core != nil {
_ = r.Core.ServiceShutdown(ctx)
}
}
```
---
## Interfaces
`pkg/core` defines several interfaces that services may implement or consume. These decouple services from concrete implementations.
### Lifecycle Interfaces
| Interface | Method | Purpose |
|-----------|--------|---------|
| `Startable` | `OnStartup(ctx) error` | Initialisation on app start |
| `Stoppable` | `OnShutdown(ctx) error` | Cleanup on app shutdown |
### Well-Known Service Interfaces
| Interface | Service name | Key methods |
|-----------|-------------|-------------|
| `Config` | `"config"` | `Get(key, out) error`, `Set(key, v) error` |
| `Display` | `"display"` | `OpenWindow(opts...) error` |
| `Workspace` | `"workspace"` | `CreateWorkspace`, `SwitchWorkspace`, `WorkspaceFileGet`, `WorkspaceFileSet` |
| `Crypt` | `"crypt"` | `CreateKeyPair`, `EncryptPGP`, `DecryptPGP` |
These interfaces live in `interfaces.go` and define the contracts that concrete implementations must satisfy.
### Contract
The `Contract` struct configures resilience behaviour:
```go
type Contract struct {
DontPanic bool // recover from panics, return errors instead
DisableLogging bool // suppress all logging
}
```
---
## Complete Example
Putting it all together -- a service that stores items, broadcasts actions, and responds to queries:
```go
package inventory
import (
"context"
"sync"
"forge.lthn.ai/core/go/pkg/core"
)
// Options configures the inventory service.
type Options struct {
MaxItems int
}
// Service manages an inventory of items.
type Service struct {
*core.ServiceRuntime[Options]
items map[string]string
mu sync.RWMutex
}
// NewService creates a factory for Core registration.
func NewService(opts Options) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
if opts.MaxItems == 0 {
opts.MaxItems = 1000
}
return &Service{
ServiceRuntime: core.NewServiceRuntime(c, opts),
items: make(map[string]string),
}, nil
}
}
// OnStartup registers query and task handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// -- Query: look up an item --
type GetItem struct{ ID string }
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch req := q.(type) {
case GetItem:
s.mu.RLock()
val, ok := s.items[req.ID]
s.mu.RUnlock()
if !ok {
return nil, true, core.E("inventory.GetItem", "not found", nil)
}
return val, true, nil
}
return nil, false, nil
}
// -- Task: add an item --
type AddItem struct {
ID string
Name string
}
type ItemAdded struct {
ID string
Name string
}
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch task := t.(type) {
case AddItem:
s.mu.Lock()
s.items[task.ID] = task.Name
s.mu.Unlock()
_ = c.ACTION(ItemAdded{ID: task.ID, Name: task.Name})
return task.ID, true, nil
}
return nil, false, nil
}
// -- Wiring it up --
func main() {
c, err := core.New(
core.WithName("inventory", NewService(Options{MaxItems: 500})),
core.WithServiceLock(),
)
if err != nil {
panic(err)
}
// Start lifecycle
_ = c.ServiceStartup(context.Background(), nil)
// Use the bus
_, _, _ = c.PERFORM(AddItem{ID: "item-1", Name: "Widget"})
result, _, _ := c.QUERY(GetItem{ID: "item-1"})
// result == "Widget"
// Shutdown
_ = c.ServiceShutdown(context.Background())
}
```
---
## File Map
| File | Responsibility |
|------|---------------|
| `core.go` | `New()`, options (`WithService`, `WithName`, `WithApp`, `WithAssets`, `WithServiceLock`), `ServiceFor[T]`, `MustServiceFor[T]`, lifecycle dispatch, global instance, bus method delegation |
| `interfaces.go` | `Core` struct definition, `Option`, `Message`, `Query`, `Task`, `QueryHandler`, `TaskHandler`, `Startable`, `Stoppable`, `Contract`, `Features`, well-known service interfaces (`Config`, `Display`, `Workspace`, `Crypt`), built-in action message types |
| `message_bus.go` | `messageBus` struct, `action()`, `query()`, `queryAll()`, `perform()`, handler registration |
| `service_manager.go` | `serviceManager` struct, service registry, `Startable`/`Stoppable` tracking, service lock mechanism |
| `runtime_pkg.go` | `ServiceRuntime[T]` generic helper, `Runtime` struct (Wails integration), `NewRuntime()`, `NewWithFactories()` |
| `e.go` | `Error` struct, `E()` constructor, `Unwrap()` for error chain compatibility |