summaryrefslogtreecommitdiff
path: root/internal/events
diff options
context:
space:
mode:
Diffstat (limited to 'internal/events')
-rw-r--r--internal/events/bus.go305
-rw-r--r--internal/events/bus_test.go746
-rw-r--r--internal/events/events.go122
3 files changed, 1173 insertions, 0 deletions
diff --git a/internal/events/bus.go b/internal/events/bus.go
new file mode 100644
index 0000000..4856c53
--- /dev/null
+++ b/internal/events/bus.go
@@ -0,0 +1,305 @@
+package events
+
+import "sync"
+
+// ── Handler interfaces ────────────────────────────────────────────────────────
+
+// RequestMiddleware is called synchronously for every HTTP request before
+// forwarding. Returning Drop=true causes the proxy to send a 403 and stop.
+// The implementation may modify Request and Body (e.g. intercept gate).
+type RequestMiddleware interface {
+ HandleRequest(RequestEvent) RequestResult
+}
+
+// ResponseObserver is called synchronously after every completed HTTP
+// transaction. Ordering is preserved: observers see responses in the exact
+// order the proxy received them.
+type ResponseObserver interface {
+ ObserveResponse(ResponseEvent)
+}
+
+// WebSocketConnectionObserver is called synchronously when a WebSocket
+// connection is established. It returns a uint64 connection ID that the
+// bus passes back to WebSocketFrameObservers as ConnectionID. Return 0
+// to indicate the connection should not be tracked.
+type WebSocketConnectionObserver interface {
+ ObserveWebSocketConnection(WebSocketConnectionEvent) uint64
+}
+
+// WebSocketFrameObserver is called synchronously for every WebSocket frame.
+// Ordering is preserved within a connection. The returned WebSocketFrameResult
+// carries the (possibly modified) payload to forward.
+type WebSocketFrameObserver interface {
+ ObserveWebSocketFrame(WebSocketFrameEvent) WebSocketFrameResult
+}
+
+// PluginLogObserver is called synchronously when a plugin emits a log line.
+// The store implements this to buffer lines in memory and notify the UI.
+type PluginLogObserver interface {
+ ObservePluginLog(PluginLogEvent)
+}
+
+// PluginEnabledObserver is called synchronously when the UI toggles a plugin.
+// Both the engine (to skip hooks) and the store (to persist state) implement this.
+type PluginEnabledObserver interface {
+ ObservePluginEnabled(SetPluginEnabledEvent)
+}
+
+// LoadPluginObserver is called synchronously when the user imports a new
+// plugin. The engine observes it to copy, load, and register the plugin.
+type LoadPluginObserver interface {
+ ObserveLoadPlugin(LoadPluginEvent)
+}
+
+// ProxyCommandObserver is implemented by the proxy to react to UI-initiated
+// control events. Restart starts or restarts the proxy on the given address.
+// Stop shuts the proxy down.
+type ProxyCommandObserver interface {
+ ObserveProxyRestart(ProxyRestartEvent)
+ ObserveProxyStop(ProxyStopEvent)
+}
+
+// ── Func adapters ─────────────────────────────────────────────────────────────
+// Allow main.go to register plain closures without declaring named types.
+
+type RequestMiddlewareFunc func(RequestEvent) RequestResult
+
+func (f RequestMiddlewareFunc) HandleRequest(e RequestEvent) RequestResult {
+ return f(e)
+}
+
+type ResponseObserverFunc func(ResponseEvent)
+
+func (f ResponseObserverFunc) ObserveResponse(e ResponseEvent) {
+ f(e)
+}
+
+type WebSocketConnectionObserverFunc func(WebSocketConnectionEvent) uint64
+
+func (f WebSocketConnectionObserverFunc) ObserveWebSocketConnection(e WebSocketConnectionEvent) uint64 {
+ return f(e)
+}
+
+type WebSocketFrameObserverFunc func(WebSocketFrameEvent) WebSocketFrameResult
+
+func (f WebSocketFrameObserverFunc) ObserveWebSocketFrame(e WebSocketFrameEvent) WebSocketFrameResult {
+ return f(e)
+}
+
+type PluginLogObserverFunc func(PluginLogEvent)
+
+func (f PluginLogObserverFunc) ObservePluginLog(e PluginLogEvent) {
+ f(e)
+}
+
+type PluginEnabledObserverFunc func(SetPluginEnabledEvent)
+
+func (f PluginEnabledObserverFunc) ObservePluginEnabled(e SetPluginEnabledEvent) {
+ f(e)
+}
+
+type LoadPluginObserverFunc func(LoadPluginEvent)
+
+func (f LoadPluginObserverFunc) ObserveLoadPlugin(e LoadPluginEvent) {
+ f(e)
+}
+
+// ── Bus ───────────────────────────────────────────────────────────────────────
+
+// Bus holds registered handlers and dispatches events to them.
+// All methods are safe for concurrent use. All emit methods run
+// synchronously to preserve the ordering guarantee that existed before
+// the refactor: store.Log emits to the Updates channel which the UI reads
+// in arrival order. Making observers async would break this.
+type Bus struct {
+ mu sync.RWMutex
+ requestMiddlewares []RequestMiddleware
+ responseObservers []ResponseObserver
+ wsConnObservers []WebSocketConnectionObserver
+ wsFrameObservers []WebSocketFrameObserver
+ pluginLogObservers []PluginLogObserver
+ pluginEnabledObservers []PluginEnabledObserver
+ pluginLoadObservers []LoadPluginObserver
+ proxyCommandObservers []ProxyCommandObserver
+}
+
+// NewBus returns a ready-to-use Bus with no handlers registered.
+func NewBus() *Bus {
+ return &Bus{}
+}
+
+// Register adds a handler to the bus. The handler is matched against all
+// handler interfaces — a single type may implement multiple interfaces
+// and will be registered for each one it satisfies. Panics if h implements
+// none of the interfaces (programming error).
+func (b *Bus) Register(h any) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ matched := false
+ if v, ok := h.(RequestMiddleware); ok {
+ b.requestMiddlewares = append(b.requestMiddlewares, v)
+ matched = true
+ }
+ if v, ok := h.(ResponseObserver); ok {
+ b.responseObservers = append(b.responseObservers, v)
+ matched = true
+ }
+ if v, ok := h.(WebSocketConnectionObserver); ok {
+ b.wsConnObservers = append(b.wsConnObservers, v)
+ matched = true
+ }
+ if v, ok := h.(WebSocketFrameObserver); ok {
+ b.wsFrameObservers = append(b.wsFrameObservers, v)
+ matched = true
+ }
+ if v, ok := h.(PluginLogObserver); ok {
+ b.pluginLogObservers = append(b.pluginLogObservers, v)
+ matched = true
+ }
+ if v, ok := h.(PluginEnabledObserver); ok {
+ b.pluginEnabledObservers = append(b.pluginEnabledObservers, v)
+ matched = true
+ }
+ if v, ok := h.(LoadPluginObserver); ok {
+ b.pluginLoadObservers = append(b.pluginLoadObservers, v)
+ matched = true
+ }
+ if v, ok := h.(ProxyCommandObserver); ok {
+ b.proxyCommandObservers = append(b.proxyCommandObservers, v)
+ matched = true
+ }
+ if !matched {
+ panic("events: Register called with a type that implements no handler interface")
+ }
+}
+
+// EmitRequest runs all RequestMiddlewares in registration order.
+// The first middleware to return Drop=true short-circuits — subsequent
+// middlewares are not called. If no middleware drops the request, the
+// final Request and Body are taken from the last middleware result.
+// If no middlewares are registered the original event values are returned
+// as a pass-through result.
+func (b *Bus) EmitRequest(e RequestEvent) RequestResult {
+ b.mu.RLock()
+ mws := b.requestMiddlewares
+ b.mu.RUnlock()
+
+ result := RequestResult{Request: e.Request, Body: e.Body}
+ for _, mw := range mws {
+ result = mw.HandleRequest(RequestEvent{Request: result.Request, Body: result.Body})
+ if result.Drop {
+ return result
+ }
+ }
+ return result
+}
+
+// EmitResponse calls all ResponseObservers in registration order.
+// Runs synchronously; returns when all observers have returned.
+func (b *Bus) EmitResponse(e ResponseEvent) {
+ b.mu.RLock()
+ obs := b.responseObservers
+ b.mu.RUnlock()
+
+ for _, o := range obs {
+ o.ObserveResponse(e)
+ }
+}
+
+// EmitWebSocketConnection calls all WebSocketConnectionObservers in
+// registration order. The first non-zero uint64 returned is used as the
+// connection ID for subsequent frame events. Runs synchronously.
+func (b *Bus) EmitWebSocketConnection(e WebSocketConnectionEvent) uint64 {
+ b.mu.RLock()
+ obs := b.wsConnObservers
+ b.mu.RUnlock()
+
+ var connID uint64
+ for _, o := range obs {
+ if id := o.ObserveWebSocketConnection(e); id != 0 && connID == 0 {
+ connID = id
+ }
+ }
+ return connID
+}
+
+// EmitWebSocketFrame calls all WebSocketFrameObservers in registration order.
+// Runs synchronously to preserve per-connection frame ordering. Each observer
+// may return a modified payload — the last non-nil payload wins. The final
+// WebSocketFrameResult carries the payload to forward.
+func (b *Bus) EmitWebSocketFrame(e WebSocketFrameEvent) WebSocketFrameResult {
+ b.mu.RLock()
+ obs := b.wsFrameObservers
+ b.mu.RUnlock()
+
+ result := WebSocketFrameResult{Payload: e.Payload}
+ for _, o := range obs {
+ r := o.ObserveWebSocketFrame(e)
+ if r.Payload != nil {
+ result.Payload = r.Payload
+ e.Payload = r.Payload
+ }
+ }
+ return result
+}
+
+// EmitPluginLog calls all PluginLogObservers in registration order.
+// Runs synchronously.
+func (b *Bus) EmitPluginLog(e PluginLogEvent) {
+ b.mu.RLock()
+ obs := b.pluginLogObservers
+ b.mu.RUnlock()
+
+ for _, o := range obs {
+ o.ObservePluginLog(e)
+ }
+}
+
+// EmitSetPluginEnabled calls all PluginEnabledObservers in registration order.
+// Runs synchronously.
+func (b *Bus) EmitSetPluginEnabled(e SetPluginEnabledEvent) {
+ b.mu.RLock()
+ obs := b.pluginEnabledObservers
+ b.mu.RUnlock()
+
+ for _, o := range obs {
+ o.ObservePluginEnabled(e)
+ }
+}
+
+// EmitLoadPlugin calls all LoadPluginObservers in registration order.
+// Runs synchronously.
+func (b *Bus) EmitLoadPlugin(e LoadPluginEvent) {
+ b.mu.RLock()
+ obs := b.pluginLoadObservers
+ b.mu.RUnlock()
+
+ for _, o := range obs {
+ o.ObserveLoadPlugin(e)
+ }
+}
+
+// EmitProxyRestart calls all ProxyCommandObservers to restart on the given address.
+// Runs synchronously.
+func (b *Bus) EmitProxyRestart(e ProxyRestartEvent) {
+ b.mu.RLock()
+ obs := b.proxyCommandObservers
+ b.mu.RUnlock()
+
+ for _, o := range obs {
+ o.ObserveProxyRestart(e)
+ }
+}
+
+// EmitProxyStop calls all ProxyCommandObservers to stop the proxy.
+// Runs synchronously.
+func (b *Bus) EmitProxyStop(e ProxyStopEvent) {
+ b.mu.RLock()
+ obs := b.proxyCommandObservers
+ b.mu.RUnlock()
+
+ for _, o := range obs {
+ o.ObserveProxyStop(e)
+ }
+}
diff --git a/internal/events/bus_test.go b/internal/events/bus_test.go
new file mode 100644
index 0000000..633d950
--- /dev/null
+++ b/internal/events/bus_test.go
@@ -0,0 +1,746 @@
+package events_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/shiv/internal/events"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+func newRequest(t *testing.T, method, url string) *http.Request {
+ t.Helper()
+ return httptest.NewRequest(method, url, nil)
+}
+
+func newRequestEvent(t *testing.T) events.RequestEvent {
+ t.Helper()
+ return events.RequestEvent{
+ Request: newRequest(t, http.MethodGet, "http://example.com/path"),
+ Body: []byte("request body"),
+ }
+}
+
+func newResponseEvent() events.ResponseEvent {
+ return events.ResponseEvent{
+ Timestamp: time.Now(),
+ Host: "example.com",
+ Proto: "HTTP/1.1",
+ Method: "GET",
+ URL: "http://example.com/path",
+ StatusCode: 200,
+ RespBody: []byte("response body"),
+ DurationMs: 42,
+ TLS: false,
+ }
+}
+
+func newWSConnectionEvent() events.WebSocketConnectionEvent {
+ return events.WebSocketConnectionEvent{
+ Host: "example.com:443",
+ URL: "wss://example.com/ws",
+ TLS: true,
+ Timestamp: time.Now(),
+ }
+}
+
+func newWSFrameEvent(connID uint64) events.WebSocketFrameEvent {
+ return events.WebSocketFrameEvent{
+ ConnectionID: connID,
+ Timestamp: time.Now(),
+ Direction: events.WebSocketClient,
+ Opcode: events.WebSocketText,
+ Payload: []byte("hello"),
+ }
+}
+
+// ── Register ──────────────────────────────────────────────────────────────────
+
+func TestRegister_PanicsOnUnknownType(t *testing.T) {
+ b := events.NewBus()
+ assert.Panics(t, func() {
+ b.Register(struct{}{})
+ })
+}
+
+func TestRegister_AcceptsRequestMiddleware(t *testing.T) {
+ b := events.NewBus()
+ require.NotPanics(t, func() {
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ })
+}
+
+func TestRegister_AcceptsResponseObserver(t *testing.T) {
+ b := events.NewBus()
+ require.NotPanics(t, func() {
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) {}))
+ })
+}
+
+func TestRegister_AcceptsWebSocketConnectionObserver(t *testing.T) {
+ b := events.NewBus()
+ require.NotPanics(t, func() {
+ b.Register(events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 { return 1 }))
+ })
+}
+
+func TestRegister_AcceptsWebSocketFrameObserver(t *testing.T) {
+ b := events.NewBus()
+ require.NotPanics(t, func() {
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ return events.WebSocketFrameResult{Payload: e.Payload}
+ }))
+ })
+}
+
+func TestRegister_AcceptsPluginLogObserver(t *testing.T) {
+ b := events.NewBus()
+ require.NotPanics(t, func() {
+ b.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) {}))
+ })
+}
+
+func TestRegister_AcceptsPluginEnabledObserver(t *testing.T) {
+ b := events.NewBus()
+ require.NotPanics(t, func() {
+ b.Register(events.PluginEnabledObserverFunc(func(e events.SetPluginEnabledEvent) {}))
+ })
+}
+
+func TestRegister_AcceptsLoadPluginObserver(t *testing.T) {
+ b := events.NewBus()
+ require.NotPanics(t, func() {
+ b.Register(events.LoadPluginObserverFunc(func(e events.LoadPluginEvent) {}))
+ })
+}
+
+func TestRegister_MultiInterfaceTypeRegisteredForAll(t *testing.T) {
+ b := events.NewBus()
+
+ var requestCalled, responseCalled, wsConnCalled, wsFrameCalled bool
+ var pluginLogCalled, pluginEnabledCalled, loadPluginCalled bool
+
+ all := &allHandler{
+ onRequest: func() { requestCalled = true },
+ onResponse: func() { responseCalled = true },
+ onWSConn: func() { wsConnCalled = true },
+ onWSFrame: func() { wsFrameCalled = true },
+ onPluginLog: func() { pluginLogCalled = true },
+ onPluginEnabled: func() { pluginEnabledCalled = true },
+ onLoadPlugin: func() { loadPluginCalled = true },
+ }
+
+ b.Register(all)
+
+ b.EmitRequest(newRequestEvent(t))
+ b.EmitResponse(newResponseEvent())
+ b.EmitWebSocketConnection(newWSConnectionEvent())
+ b.EmitWebSocketFrame(newWSFrameEvent(1))
+ b.EmitPluginLog(events.PluginLogEvent{Name: "test.lua", Message: "hello"})
+ b.EmitSetPluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: true})
+ b.EmitLoadPlugin(events.LoadPluginEvent{SourcePath: "/tmp/test.lua"})
+
+ assert.True(t, requestCalled)
+ assert.True(t, responseCalled)
+ assert.True(t, wsConnCalled)
+ assert.True(t, wsFrameCalled)
+ assert.True(t, pluginLogCalled)
+ assert.True(t, pluginEnabledCalled)
+ assert.True(t, loadPluginCalled)
+}
+
+// ── EmitRequest ───────────────────────────────────────────────────────────────
+
+func TestEmitRequest_NoHandlers_PassThrough(t *testing.T) {
+ b := events.NewBus()
+ ev := newRequestEvent(t)
+ result := b.EmitRequest(ev)
+ assert.False(t, result.Drop)
+ assert.Equal(t, ev.Request, result.Request)
+ assert.Equal(t, ev.Body, result.Body)
+}
+
+func TestEmitRequest_SingleMiddleware_PassThrough(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ ev := newRequestEvent(t)
+ result := b.EmitRequest(ev)
+ assert.False(t, result.Drop)
+ assert.Equal(t, ev.Request, result.Request)
+}
+
+func TestEmitRequest_MiddlewareDrop_ReturnsDrop(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ return events.RequestResult{Drop: true, Request: e.Request, Body: e.Body}
+ }))
+ result := b.EmitRequest(newRequestEvent(t))
+ assert.True(t, result.Drop)
+}
+
+func TestEmitRequest_FirstDropShortCircuits(t *testing.T) {
+ b := events.NewBus()
+ secondCalled := false
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ return events.RequestResult{Drop: true, Request: e.Request, Body: e.Body}
+ }))
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ secondCalled = true
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ b.EmitRequest(newRequestEvent(t))
+ assert.False(t, secondCalled)
+}
+
+func TestEmitRequest_MiddlewareModifiesBody(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ return events.RequestResult{Request: e.Request, Body: []byte("modified body")}
+ }))
+ result := b.EmitRequest(newRequestEvent(t))
+ assert.False(t, result.Drop)
+ assert.Equal(t, []byte("modified body"), result.Body)
+}
+
+func TestEmitRequest_MiddlewareModifiesRequest(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ e.Request.Header.Set("X-Injected", "yes")
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ result := b.EmitRequest(newRequestEvent(t))
+ assert.Equal(t, "yes", result.Request.Header.Get("X-Injected"))
+}
+
+func TestEmitRequest_ChainedMiddlewares_ModificationsAccumulate(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ return events.RequestResult{Request: e.Request, Body: []byte("step1")}
+ }))
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ return events.RequestResult{Request: e.Request, Body: append(e.Body, []byte("-step2")...)}
+ }))
+ result := b.EmitRequest(newRequestEvent(t))
+ assert.Equal(t, []byte("step1-step2"), result.Body)
+}
+
+func TestEmitRequest_MiddlewareCalledInRegistrationOrder(t *testing.T) {
+ b := events.NewBus()
+ var order []int
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ order = append(order, 1)
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ order = append(order, 2)
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ order = append(order, 3)
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ b.EmitRequest(newRequestEvent(t))
+ assert.Equal(t, []int{1, 2, 3}, order)
+}
+
+// ── EmitResponse ──────────────────────────────────────────────────────────────
+
+func TestEmitResponse_NoHandlers_NoPanic(t *testing.T) {
+ b := events.NewBus()
+ assert.NotPanics(t, func() { b.EmitResponse(newResponseEvent()) })
+}
+
+func TestEmitResponse_ObserverReceivesEvent(t *testing.T) {
+ b := events.NewBus()
+ var received events.ResponseEvent
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { received = e }))
+ ev := newResponseEvent()
+ b.EmitResponse(ev)
+ assert.Equal(t, ev.Host, received.Host)
+ assert.Equal(t, ev.StatusCode, received.StatusCode)
+ assert.Equal(t, ev.DurationMs, received.DurationMs)
+}
+
+func TestEmitResponse_MultipleObserversAllCalled(t *testing.T) {
+ b := events.NewBus()
+ var calls int
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { calls++ }))
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { calls++ }))
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { calls++ }))
+ b.EmitResponse(newResponseEvent())
+ assert.Equal(t, 3, calls)
+}
+
+func TestEmitResponse_ObserversCalledInRegistrationOrder(t *testing.T) {
+ b := events.NewBus()
+ var order []int
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { order = append(order, 1) }))
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { order = append(order, 2) }))
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { order = append(order, 3) }))
+ b.EmitResponse(newResponseEvent())
+ assert.Equal(t, []int{1, 2, 3}, order)
+}
+
+func TestEmitResponse_IsSynchronous(t *testing.T) {
+ b := events.NewBus()
+ done := false
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { done = true }))
+ b.EmitResponse(newResponseEvent())
+ assert.True(t, done)
+}
+
+// ── EmitWebSocketConnection ───────────────────────────────────────────────────
+
+func TestEmitWebSocketConnection_NoHandlers_ReturnsZero(t *testing.T) {
+ b := events.NewBus()
+ assert.Equal(t, uint64(0), b.EmitWebSocketConnection(newWSConnectionEvent()))
+}
+
+func TestEmitWebSocketConnection_ObserverReceivesEvent(t *testing.T) {
+ b := events.NewBus()
+ var received events.WebSocketConnectionEvent
+ b.Register(events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 {
+ received = e
+ return 1
+ }))
+ ev := newWSConnectionEvent()
+ b.EmitWebSocketConnection(ev)
+ assert.Equal(t, ev.Host, received.Host)
+ assert.Equal(t, ev.URL, received.URL)
+ assert.Equal(t, ev.TLS, received.TLS)
+}
+
+func TestEmitWebSocketConnection_ReturnsFirstNonZeroID(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 { return 0 }))
+ b.Register(events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 { return 42 }))
+ b.Register(events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 { return 99 }))
+ assert.Equal(t, uint64(42), b.EmitWebSocketConnection(newWSConnectionEvent()))
+}
+
+func TestEmitWebSocketConnection_AllObserversCalledEvenAfterNonZero(t *testing.T) {
+ b := events.NewBus()
+ var calls int
+ b.Register(events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 { calls++; return 1 }))
+ b.Register(events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 { calls++; return 2 }))
+ b.EmitWebSocketConnection(newWSConnectionEvent())
+ assert.Equal(t, 2, calls)
+}
+
+func TestEmitWebSocketConnection_ZeroReturnNotUsedAsID(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 { return 0 }))
+ assert.Equal(t, uint64(0), b.EmitWebSocketConnection(newWSConnectionEvent()))
+}
+
+// ── EmitWebSocketFrame ────────────────────────────────────────────────────────
+
+func TestEmitWebSocketFrame_NoHandlers_ReturnsOriginalPayload(t *testing.T) {
+ b := events.NewBus()
+ ev := newWSFrameEvent(1)
+ result := b.EmitWebSocketFrame(ev)
+ assert.Equal(t, ev.Payload, result.Payload)
+}
+
+func TestEmitWebSocketFrame_ObserverReceivesEvent(t *testing.T) {
+ b := events.NewBus()
+ var received events.WebSocketFrameEvent
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ received = e
+ return events.WebSocketFrameResult{Payload: e.Payload}
+ }))
+ ev := newWSFrameEvent(7)
+ b.EmitWebSocketFrame(ev)
+ assert.Equal(t, uint64(7), received.ConnectionID)
+ assert.Equal(t, events.WebSocketClient, received.Direction)
+ assert.Equal(t, events.WebSocketText, received.Opcode)
+ assert.Equal(t, []byte("hello"), received.Payload)
+}
+
+func TestEmitWebSocketFrame_ObserverModifiesPayload(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ return events.WebSocketFrameResult{Payload: []byte("modified")}
+ }))
+ result := b.EmitWebSocketFrame(newWSFrameEvent(1))
+ assert.Equal(t, []byte("modified"), result.Payload)
+}
+
+func TestEmitWebSocketFrame_ChainedObservers_ModificationsAccumulate(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ return events.WebSocketFrameResult{Payload: []byte("step1")}
+ }))
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ return events.WebSocketFrameResult{Payload: append(e.Payload, []byte("-step2")...)}
+ }))
+ result := b.EmitWebSocketFrame(newWSFrameEvent(1))
+ assert.Equal(t, []byte("step1-step2"), result.Payload)
+}
+
+func TestEmitWebSocketFrame_NilPayloadResult_KeepsPrevious(t *testing.T) {
+ b := events.NewBus()
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ return events.WebSocketFrameResult{Payload: []byte("keep-me")}
+ }))
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ return events.WebSocketFrameResult{Payload: nil}
+ }))
+ result := b.EmitWebSocketFrame(newWSFrameEvent(1))
+ assert.Equal(t, []byte("keep-me"), result.Payload)
+}
+
+func TestEmitWebSocketFrame_MultipleObserversAllCalled(t *testing.T) {
+ b := events.NewBus()
+ var calls int
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ calls++
+ return events.WebSocketFrameResult{Payload: e.Payload}
+ }))
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ calls++
+ return events.WebSocketFrameResult{Payload: e.Payload}
+ }))
+ b.EmitWebSocketFrame(newWSFrameEvent(1))
+ assert.Equal(t, 2, calls)
+}
+
+func TestEmitWebSocketFrame_OrderingPreserved(t *testing.T) {
+ b := events.NewBus()
+ var received []uint64
+ b.Register(events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ received = append(received, e.ConnectionID)
+ return events.WebSocketFrameResult{Payload: e.Payload}
+ }))
+ b.EmitWebSocketFrame(events.WebSocketFrameEvent{ConnectionID: 1, Payload: []byte("a")})
+ b.EmitWebSocketFrame(events.WebSocketFrameEvent{ConnectionID: 2, Payload: []byte("b")})
+ b.EmitWebSocketFrame(events.WebSocketFrameEvent{ConnectionID: 3, Payload: []byte("c")})
+ assert.Equal(t, []uint64{1, 2, 3}, received)
+}
+
+// ── EmitPluginLog ─────────────────────────────────────────────────────────────
+
+func TestEmitPluginLog_NoHandlers_NoPanic(t *testing.T) {
+ b := events.NewBus()
+ assert.NotPanics(t, func() {
+ b.EmitPluginLog(events.PluginLogEvent{Name: "test.lua", Message: "hello"})
+ })
+}
+
+func TestEmitPluginLog_ObserverReceivesEvent(t *testing.T) {
+ b := events.NewBus()
+ var received events.PluginLogEvent
+ b.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) { received = e }))
+ b.EmitPluginLog(events.PluginLogEvent{Name: "myplugin.lua", Message: "log line"})
+ assert.Equal(t, "myplugin.lua", received.Name)
+ assert.Equal(t, "log line", received.Message)
+}
+
+func TestEmitPluginLog_MultipleObserversAllCalled(t *testing.T) {
+ b := events.NewBus()
+ var calls int
+ b.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) { calls++ }))
+ b.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) { calls++ }))
+ b.EmitPluginLog(events.PluginLogEvent{Name: "test.lua", Message: "msg"})
+ assert.Equal(t, 2, calls)
+}
+
+func TestEmitPluginLog_IsSynchronous(t *testing.T) {
+ b := events.NewBus()
+ done := false
+ b.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) { done = true }))
+ b.EmitPluginLog(events.PluginLogEvent{Name: "test.lua", Message: "msg"})
+ assert.True(t, done)
+}
+
+// ── EmitSetPluginEnabled ──────────────────────────────────────────────────────
+
+func TestEmitSetPluginEnabled_NoHandlers_NoPanic(t *testing.T) {
+ b := events.NewBus()
+ assert.NotPanics(t, func() {
+ b.EmitSetPluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+ })
+}
+
+func TestEmitSetPluginEnabled_ObserverReceivesEvent(t *testing.T) {
+ b := events.NewBus()
+ var received events.SetPluginEnabledEvent
+ b.Register(events.PluginEnabledObserverFunc(func(e events.SetPluginEnabledEvent) { received = e }))
+ b.EmitSetPluginEnabled(events.SetPluginEnabledEvent{Name: "myplugin.lua", Enabled: false})
+ assert.Equal(t, "myplugin.lua", received.Name)
+ assert.False(t, received.Enabled)
+}
+
+func TestEmitSetPluginEnabled_MultipleObserversAllCalled(t *testing.T) {
+ b := events.NewBus()
+ var calls int
+ b.Register(events.PluginEnabledObserverFunc(func(e events.SetPluginEnabledEvent) { calls++ }))
+ b.Register(events.PluginEnabledObserverFunc(func(e events.SetPluginEnabledEvent) { calls++ }))
+ b.EmitSetPluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: true})
+ assert.Equal(t, 2, calls)
+}
+
+func TestEmitSetPluginEnabled_EnabledFlagPropagated(t *testing.T) {
+ b := events.NewBus()
+ var gotEnabled bool
+ b.Register(events.PluginEnabledObserverFunc(func(e events.SetPluginEnabledEvent) { gotEnabled = e.Enabled }))
+ b.EmitSetPluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: true})
+ assert.True(t, gotEnabled)
+ b.EmitSetPluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: false})
+ assert.False(t, gotEnabled)
+}
+
+// ── EmitLoadPlugin ────────────────────────────────────────────────────────────
+
+func TestEmitLoadPlugin_NoHandlers_NoPanic(t *testing.T) {
+ b := events.NewBus()
+ assert.NotPanics(t, func() {
+ b.EmitLoadPlugin(events.LoadPluginEvent{SourcePath: "/tmp/test.lua"})
+ })
+}
+
+func TestEmitLoadPlugin_ObserverReceivesEvent(t *testing.T) {
+ b := events.NewBus()
+ var received events.LoadPluginEvent
+ b.Register(events.LoadPluginObserverFunc(func(e events.LoadPluginEvent) { received = e }))
+ b.EmitLoadPlugin(events.LoadPluginEvent{SourcePath: "/home/user/myplugin.lua"})
+ assert.Equal(t, "/home/user/myplugin.lua", received.SourcePath)
+}
+
+func TestEmitLoadPlugin_MultipleObserversAllCalled(t *testing.T) {
+ b := events.NewBus()
+ var calls int
+ b.Register(events.LoadPluginObserverFunc(func(e events.LoadPluginEvent) { calls++ }))
+ b.Register(events.LoadPluginObserverFunc(func(e events.LoadPluginEvent) { calls++ }))
+ b.EmitLoadPlugin(events.LoadPluginEvent{SourcePath: "/tmp/test.lua"})
+ assert.Equal(t, 2, calls)
+}
+
+func TestEmitLoadPlugin_IsSynchronous(t *testing.T) {
+ b := events.NewBus()
+ done := false
+ b.Register(events.LoadPluginObserverFunc(func(e events.LoadPluginEvent) { done = true }))
+ b.EmitLoadPlugin(events.LoadPluginEvent{SourcePath: "/tmp/test.lua"})
+ assert.True(t, done)
+}
+
+// ── Func adapters ─────────────────────────────────────────────────────────────
+
+func TestRequestMiddlewareFunc_ImplementsInterface(t *testing.T) {
+ var _ events.RequestMiddleware = events.RequestMiddlewareFunc(nil)
+}
+
+func TestResponseObserverFunc_ImplementsInterface(t *testing.T) {
+ var _ events.ResponseObserver = events.ResponseObserverFunc(nil)
+}
+
+func TestWebSocketConnectionObserverFunc_ImplementsInterface(t *testing.T) {
+ var _ events.WebSocketConnectionObserver = events.WebSocketConnectionObserverFunc(nil)
+}
+
+func TestWebSocketFrameObserverFunc_ImplementsInterface(t *testing.T) {
+ var _ events.WebSocketFrameObserver = events.WebSocketFrameObserverFunc(nil)
+}
+
+func TestPluginLogObserverFunc_ImplementsInterface(t *testing.T) {
+ var _ events.PluginLogObserver = events.PluginLogObserverFunc(nil)
+}
+
+func TestPluginEnabledObserverFunc_ImplementsInterface(t *testing.T) {
+ var _ events.PluginEnabledObserver = events.PluginEnabledObserverFunc(nil)
+}
+
+func TestLoadPluginObserverFunc_ImplementsInterface(t *testing.T) {
+ var _ events.LoadPluginObserver = events.LoadPluginObserverFunc(nil)
+}
+
+func TestRequestMiddlewareFunc_DelegatesCall(t *testing.T) {
+ called := false
+ f := events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ called = true
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ })
+ f.HandleRequest(events.RequestEvent{Request: newRequest(t, http.MethodGet, "http://example.com")})
+ assert.True(t, called)
+}
+
+func TestResponseObserverFunc_DelegatesCall(t *testing.T) {
+ called := false
+ f := events.ResponseObserverFunc(func(e events.ResponseEvent) { called = true })
+ f.ObserveResponse(newResponseEvent())
+ assert.True(t, called)
+}
+
+func TestWebSocketConnectionObserverFunc_DelegatesCall(t *testing.T) {
+ f := events.WebSocketConnectionObserverFunc(func(e events.WebSocketConnectionEvent) uint64 { return 55 })
+ assert.Equal(t, uint64(55), f.ObserveWebSocketConnection(newWSConnectionEvent()))
+}
+
+func TestWebSocketFrameObserverFunc_DelegatesCall(t *testing.T) {
+ called := false
+ f := events.WebSocketFrameObserverFunc(func(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ called = true
+ return events.WebSocketFrameResult{Payload: e.Payload}
+ })
+ f.ObserveWebSocketFrame(newWSFrameEvent(1))
+ assert.True(t, called)
+}
+
+func TestPluginLogObserverFunc_DelegatesCall(t *testing.T) {
+ called := false
+ f := events.PluginLogObserverFunc(func(e events.PluginLogEvent) { called = true })
+ f.ObservePluginLog(events.PluginLogEvent{Name: "test.lua", Message: "hello"})
+ assert.True(t, called)
+}
+
+func TestPluginEnabledObserverFunc_DelegatesCall(t *testing.T) {
+ var got events.SetPluginEnabledEvent
+ f := events.PluginEnabledObserverFunc(func(e events.SetPluginEnabledEvent) { got = e })
+ f.ObservePluginEnabled(events.SetPluginEnabledEvent{Name: "test.lua", Enabled: true})
+ assert.Equal(t, "test.lua", got.Name)
+ assert.True(t, got.Enabled)
+}
+
+func TestLoadPluginObserverFunc_DelegatesCall(t *testing.T) {
+ var got events.LoadPluginEvent
+ f := events.LoadPluginObserverFunc(func(e events.LoadPluginEvent) { got = e })
+ f.ObserveLoadPlugin(events.LoadPluginEvent{SourcePath: "/tmp/test.lua"})
+ assert.Equal(t, "/tmp/test.lua", got.SourcePath)
+}
+
+// ── Concurrency ───────────────────────────────────────────────────────────────
+
+func TestBus_ConcurrentEmitRequest_Safe(t *testing.T) {
+ b := events.NewBus()
+ var count atomic.Int64
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ count.Add(1)
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ const goroutines = 50
+ var wg sync.WaitGroup
+ wg.Add(goroutines)
+ for range goroutines {
+ go func() {
+ defer wg.Done()
+ b.EmitRequest(events.RequestEvent{Request: httptest.NewRequest(http.MethodGet, "http://example.com", nil)})
+ }()
+ }
+ wg.Wait()
+ assert.Equal(t, int64(goroutines), count.Load())
+}
+
+func TestBus_ConcurrentEmitResponse_Safe(t *testing.T) {
+ b := events.NewBus()
+ var count atomic.Int64
+ b.Register(events.ResponseObserverFunc(func(e events.ResponseEvent) { count.Add(1) }))
+ const goroutines = 50
+ var wg sync.WaitGroup
+ wg.Add(goroutines)
+ for range goroutines {
+ go func() {
+ defer wg.Done()
+ b.EmitResponse(newResponseEvent())
+ }()
+ }
+ wg.Wait()
+ assert.Equal(t, int64(goroutines), count.Load())
+}
+
+func TestBus_ConcurrentEmitPluginLog_Safe(t *testing.T) {
+ b := events.NewBus()
+ var count atomic.Int64
+ b.Register(events.PluginLogObserverFunc(func(e events.PluginLogEvent) { count.Add(1) }))
+ const goroutines = 50
+ var wg sync.WaitGroup
+ wg.Add(goroutines)
+ for range goroutines {
+ go func() {
+ defer wg.Done()
+ b.EmitPluginLog(events.PluginLogEvent{Name: "test.lua", Message: "msg"})
+ }()
+ }
+ wg.Wait()
+ assert.Equal(t, int64(goroutines), count.Load())
+}
+
+func TestBus_ConcurrentRegisterAndEmit_Safe(t *testing.T) {
+ b := events.NewBus()
+ var wg sync.WaitGroup
+ for range 20 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ b.EmitRequest(events.RequestEvent{Request: httptest.NewRequest(http.MethodGet, "http://example.com", nil)})
+ }()
+ }
+ for range 20 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ b.Register(events.RequestMiddlewareFunc(func(e events.RequestEvent) events.RequestResult {
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+ }))
+ }()
+ }
+ wg.Wait()
+}
+
+// ── WebSocket direction and opcode constants ──────────────────────────────────
+
+func TestWebSocketDirection_Values(t *testing.T) {
+ assert.Equal(t, events.WebSocketDirection(0), events.WebSocketClient)
+ assert.Equal(t, events.WebSocketDirection(1), events.WebSocketServer)
+}
+
+func TestWebSocketOpcode_Values(t *testing.T) {
+ assert.Equal(t, events.WebSocketOpcode(1), events.WebSocketText)
+ assert.Equal(t, events.WebSocketOpcode(2), events.WebSocketBinary)
+ assert.Equal(t, events.WebSocketOpcode(8), events.WebSocketClose)
+ assert.Equal(t, events.WebSocketOpcode(9), events.WebSocketPing)
+ assert.Equal(t, events.WebSocketOpcode(10), events.WebSocketPong)
+}
+
+// ── allHandler — test double implementing all seven interfaces ────────────────
+
+type allHandler struct {
+ onRequest func()
+ onResponse func()
+ onWSConn func()
+ onWSFrame func()
+ onPluginLog func()
+ onPluginEnabled func()
+ onLoadPlugin func()
+}
+
+func (h *allHandler) HandleRequest(e events.RequestEvent) events.RequestResult {
+ h.onRequest()
+ return events.RequestResult{Request: e.Request, Body: e.Body}
+}
+
+func (h *allHandler) ObserveResponse(e events.ResponseEvent) { h.onResponse() }
+
+func (h *allHandler) ObserveWebSocketConnection(e events.WebSocketConnectionEvent) uint64 {
+ h.onWSConn()
+ return 1
+}
+
+func (h *allHandler) ObserveWebSocketFrame(e events.WebSocketFrameEvent) events.WebSocketFrameResult {
+ h.onWSFrame()
+ return events.WebSocketFrameResult{Payload: e.Payload}
+}
+
+func (h *allHandler) ObservePluginLog(e events.PluginLogEvent) { h.onPluginLog() }
+func (h *allHandler) ObservePluginEnabled(e events.SetPluginEnabledEvent) { h.onPluginEnabled() }
+func (h *allHandler) ObserveLoadPlugin(e events.LoadPluginEvent) { h.onLoadPlugin() }
diff --git a/internal/events/events.go b/internal/events/events.go
new file mode 100644
index 0000000..5258a21
--- /dev/null
+++ b/internal/events/events.go
@@ -0,0 +1,122 @@
+package events
+
+import (
+ "net/http"
+ "time"
+)
+
+// RequestEvent carries a fully-read HTTP request and its body, ready to be
+// forwarded. The proxy emits this before forwarding. Consumers may inspect
+// or modify the request. The result controls whether forwarding proceeds.
+type RequestEvent struct {
+ Request *http.Request
+ Body []byte
+}
+
+// ResponseEvent carries a completed HTTP transaction. The proxy emits this
+// after the upstream response is fully received and the body is decompressed.
+// All fields are populated; no further store knowledge is required.
+type ResponseEvent struct {
+ Timestamp time.Time
+ Host string
+ Proto string // "HTTP/1.1" or "HTTP/2"
+ Method string
+ URL string
+ ReqHeaders http.Header
+ ReqBody []byte
+ StatusCode int
+ RespHeaders http.Header
+ RespBody []byte // decompressed; nil if binary
+ DurationMs int64
+ TLS bool
+}
+
+// RequestResult is returned by RequestMiddleware. If Drop is true the proxy
+// sends a 403 and does not forward. Request and Body are the (possibly
+// modified) request to forward when Drop is false.
+type RequestResult struct {
+ Drop bool
+ Request *http.Request
+ Body []byte
+}
+
+// WebSocketDirection indicates which endpoint sent a frame.
+type WebSocketDirection int
+
+const (
+ WebSocketClient WebSocketDirection = 0 // browser → server
+ WebSocketServer WebSocketDirection = 1 // server → browser
+)
+
+// WebSocketOpcode mirrors gorilla/websocket message type constants.
+type WebSocketOpcode int
+
+const (
+ WebSocketText WebSocketOpcode = 1
+ WebSocketBinary WebSocketOpcode = 2
+ WebSocketPing WebSocketOpcode = 9
+ WebSocketPong WebSocketOpcode = 10
+ WebSocketClose WebSocketOpcode = 8
+)
+
+// WebSocketConnectionEvent is emitted once when a WebSocket upgrade handshake
+// completes successfully. The proxy emits this after the upstream dial and
+// browser upgrade both succeed.
+type WebSocketConnectionEvent struct {
+ Host string
+ URL string
+ TLS bool
+ Timestamp time.Time
+}
+
+// WebSocketFrameResult is returned by WebSocketFrameObserver. Payload is the
+// (possibly modified) payload to forward. If Payload is nil the original is used.
+type WebSocketFrameResult struct {
+ Payload []byte
+}
+
+// WebSocketFrameEvent is emitted for every proxied WebSocket frame in either
+// direction. ConnectionID is the value returned by the
+// WebSocketConnectionObserver that handled the corresponding
+// WebSocketConnectionEvent.
+type WebSocketFrameEvent struct {
+ ConnectionID uint64
+ Timestamp time.Time
+ Direction WebSocketDirection
+ Opcode WebSocketOpcode
+ Payload []byte
+}
+
+// PluginLogEvent is emitted by the plugin engine when a Lua plugin calls
+// log(). The store observes it, buffers the line in memory, and notifies
+// the UI via the PluginEntries channel.
+type PluginLogEvent struct {
+ Name string // plugin filename (e.g. "myplugin.lua")
+ Message string
+}
+
+// SetPluginEnabledEvent is emitted by the UI when the user toggles a plugin.
+// The engine observes it to skip or include that plugin's hooks.
+// The store observes it to persist the new state to the database.
+type SetPluginEnabledEvent struct {
+ Name string // plugin filename
+ Enabled bool
+}
+
+// LoadPluginEvent is emitted by the UI when the user selects a .lua file to
+// import. The engine observes it, copies the file into the plugins directory,
+// loads it, and registers it for subsequent hook calls.
+type LoadPluginEvent struct {
+ SourcePath string // absolute path to the selected .lua file
+}
+
+// ProxyRestartEvent is emitted by the UI when the user saves proxy settings
+// or toggles the proxy on. Addr is the new listen address (host:port).
+// The proxy observes this and restarts on the new address.
+type ProxyRestartEvent struct {
+ Addr string
+}
+
+// ProxyStopEvent is emitted by the UI when the user toggles the proxy off.
+// The proxy observes this and stops listening.
+type ProxyStopEvent struct{}