diff options
| author | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
|---|---|---|
| committer | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
| commit | 0a477fd288d064d265395c88dbd0f261b723804e (patch) | |
| tree | 9b129f8f41c907285b89ed8385679934bb59c14f /internal/events | |
Initial release
Diffstat (limited to 'internal/events')
| -rw-r--r-- | internal/events/bus.go | 305 | ||||
| -rw-r--r-- | internal/events/bus_test.go | 746 | ||||
| -rw-r--r-- | internal/events/events.go | 122 |
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{} |
