diff --git a/server/Makefile b/server/Makefile index ce551d54..b3f7f90e 100644 --- a/server/Makefile +++ b/server/Makefile @@ -19,7 +19,7 @@ oapi-generate: openapi-down-convert --input openapi.yaml --output openapi-3.0.yaml go tool oapi-codegen -config ./oapi-codegen.yaml ./openapi-3.0.yaml @echo "Fixing oapi-codegen issue https://github.com/oapi-codegen/oapi-codegen/issues/1764..." - go run ./scripts/oapi/patch_sse_methods -file ./lib/oapi/oapi.go -expected-replacements 3 + go run ./scripts/oapi/patch_sse_methods -file ./lib/oapi/oapi.go -expected-replacements 4 go fmt ./lib/oapi/oapi.go go mod tidy diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 4bfc0d82..84164b26 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/kernel/kernel-images/server/lib/capturesession" "github.com/kernel/kernel-images/server/lib/cdpmonitor" "github.com/kernel/kernel-images/server/lib/devtoolsproxy" "github.com/kernel/kernel-images/server/lib/events" @@ -81,7 +82,8 @@ type ApiService struct { xvfbResizeMu sync.Mutex // CDP event pipeline and cdpMonitor. - captureSession *events.CaptureSession + eventStream *events.EventStream + captureSession *capturesession.CaptureSession cdpMonitor cdpMonitorController monitorMu sync.Mutex lifecycleCtx context.Context @@ -96,7 +98,8 @@ func New( upstreamMgr *devtoolsproxy.UpstreamManager, stz scaletozero.Controller, nekoAuthClient *nekoclient.AuthClient, - captureSession *events.CaptureSession, + captureSession *capturesession.CaptureSession, + eventStream *events.EventStream, displayNum int, ) (*ApiService, error) { switch { @@ -110,6 +113,8 @@ func New( return nil, fmt.Errorf("nekoAuthClient cannot be nil") case captureSession == nil: return nil, fmt.Errorf("captureSession cannot be nil") + case eventStream == nil: + return nil, fmt.Errorf("eventStream cannot be nil") } mon := cdpmonitor.New(upstreamMgr, captureSession.Publish, displayNum, slog.Default()) @@ -125,6 +130,7 @@ func New( stz: stz, nekoAuthClient: nekoAuthClient, policy: &policy.Policy{}, + eventStream: eventStream, captureSession: captureSession, cdpMonitor: mon, lifecycleCtx: ctx, @@ -352,7 +358,6 @@ func (s *ApiService) Shutdown(ctx context.Context) error { s.lifecycleCancel() s.cdpMonitor.Stop() s.captureSession.Stop() - _ = s.captureSession.Close() s.monitorMu.Unlock() return s.recordManager.StopAll(ctx) } diff --git a/server/cmd/api/api/api_test.go b/server/cmd/api/api/api_test.go index cb5e9afc..02835ab1 100644 --- a/server/cmd/api/api/api_test.go +++ b/server/cmd/api/api/api_test.go @@ -11,6 +11,7 @@ import ( "log/slog" + "github.com/kernel/kernel-images/server/lib/capturesession" "github.com/kernel/kernel-images/server/lib/devtoolsproxy" "github.com/kernel/kernel-images/server/lib/events" "github.com/kernel/kernel-images/server/lib/nekoclient" @@ -26,7 +27,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("success", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) resp, err := svc.StartRecording(ctx, oapi.StartRecordingRequestObject{}) @@ -40,7 +41,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("already recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) // First start should succeed @@ -55,7 +56,7 @@ func TestApiService_StartRecording(t *testing.T) { t.Run("custom ids don't collide", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) for i := 0; i < 5; i++ { @@ -88,7 +89,7 @@ func TestApiService_StopRecording(t *testing.T) { t.Run("no active recording", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) @@ -101,7 +102,7 @@ func TestApiService_StopRecording(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) resp, err := svc.StopRecording(ctx, oapi.StopRecordingRequestObject{}) require.NoError(t, err) @@ -116,7 +117,7 @@ func TestApiService_StopRecording(t *testing.T) { force := true req := oapi.StopRecordingRequestObject{Body: &oapi.StopRecordingJSONRequestBody{ForceStop: &force}} - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) resp, err := svc.StopRecording(ctx, req) require.NoError(t, err) @@ -130,7 +131,7 @@ func TestApiService_DownloadRecording(t *testing.T) { t.Run("not found", func(t *testing.T) { mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) @@ -150,7 +151,7 @@ func TestApiService_DownloadRecording(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true, recordingData: randomBytes(minRecordingSizeInBytes - 1)} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) // will return a 202 when the recording is too small resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) @@ -180,7 +181,7 @@ func TestApiService_DownloadRecording(t *testing.T) { rec := &mockRecorder{id: "default", recordingData: data} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) resp, err := svc.DownloadRecording(ctx, oapi.DownloadRecordingRequestObject{}) require.NoError(t, err) @@ -200,7 +201,7 @@ func TestApiService_Shutdown(t *testing.T) { rec := &mockRecorder{id: "default", isRecordingFlag: true} require.NoError(t, mgr.RegisterRecorder(ctx, rec), "failed to register recorder") - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) require.NoError(t, svc.Shutdown(ctx)) @@ -304,23 +305,26 @@ func newMockNekoClient(t *testing.T) *nekoclient.AuthClient { return client } -func newCaptureSession(t *testing.T) *events.CaptureSession { +func newCaptureSession(t *testing.T) (*capturesession.CaptureSession, *events.EventStream) { t.Helper() - cs, err := events.NewCaptureSession(events.CaptureSessionConfig{ - LogDir: t.TempDir(), - RingCapacity: 64, - }) + es, err := events.NewEventStream(events.EventStreamConfig{RingCapacity: 64}) if err != nil { t.Fatal(err) } - t.Cleanup(func() { cs.Close() }) - return cs + return capturesession.NewCaptureSession(es), es +} + +// newSvc constructs an ApiService with a fresh capture session and event stream. +func newSvc(t *testing.T, mgr recorder.RecordManager) (*ApiService, error) { + t.Helper() + cs, es := newCaptureSession(t) + return New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), cs, es, 0) } func TestApiService_PatchChromiumFlags(t *testing.T) { ctx := context.Background() mgr := recorder.NewFFmpegManager() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + svc, err := newSvc(t, mgr) require.NoError(t, err) // Test with valid flags diff --git a/server/cmd/api/api/capture_session.go b/server/cmd/api/api/capture_session.go index 6ffaa251..b69ed84a 100644 --- a/server/cmd/api/api/capture_session.go +++ b/server/cmd/api/api/capture_session.go @@ -8,6 +8,7 @@ import ( "github.com/nrednav/cuid2" oapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/kernel/kernel-images/server/lib/capturesession" "github.com/kernel/kernel-images/server/lib/events" "github.com/kernel/kernel-images/server/lib/logger" ) @@ -119,26 +120,26 @@ func (s *ApiService) buildSessionResponse() oapi.CaptureSession { } // captureConfigFrom converts the optional StartCaptureSessionRequest body -// into an events.CaptureConfig. -func captureConfigFrom(body *oapi.StartCaptureSessionRequest) (events.CaptureConfig, error) { +// into a capturesession.CaptureConfig. +func captureConfigFrom(body *oapi.StartCaptureSessionRequest) (capturesession.CaptureConfig, error) { if body == nil { - return events.CaptureConfig{}, nil + return capturesession.CaptureConfig{}, nil } return captureConfigFromOAPI(body.Config) } -// captureConfigFromOAPI converts an oapi.CaptureConfig to events.CaptureConfig. -func captureConfigFromOAPI(cfg *oapi.CaptureConfig) (events.CaptureConfig, error) { +// captureConfigFromOAPI converts an oapi.CaptureConfig to capturesession.CaptureConfig. +func captureConfigFromOAPI(cfg *oapi.CaptureConfig) (capturesession.CaptureConfig, error) { if cfg == nil || cfg.Categories == nil { - return events.CaptureConfig{}, nil + return capturesession.CaptureConfig{}, nil } - out := events.CaptureConfig{ + out := capturesession.CaptureConfig{ Categories: make([]events.EventCategory, 0, len(*cfg.Categories)), } for _, c := range *cfg.Categories { cat := events.EventCategory(c) if !events.ValidCategory(cat) { - return events.CaptureConfig{}, fmt.Errorf("unknown category: %q", c) + return capturesession.CaptureConfig{}, fmt.Errorf("unknown category: %q", c) } out.Categories = append(out.Categories, cat) } diff --git a/server/cmd/api/api/capture_session_test.go b/server/cmd/api/api/capture_session_test.go index e6edd971..032cbb77 100644 --- a/server/cmd/api/api/capture_session_test.go +++ b/server/cmd/api/api/capture_session_test.go @@ -19,7 +19,7 @@ func TestCaptureConfigFrom(t *testing.T) { }) t.Run("valid categories", func(t *testing.T) { - cats := []oapi.CaptureConfigCategories{oapi.Console, oapi.Network} + cats := []oapi.CaptureConfigCategories{oapi.CaptureConfigCategoriesConsole, oapi.CaptureConfigCategoriesNetwork} body := &oapi.StartCaptureSessionRequest{ Config: &oapi.CaptureConfig{Categories: &cats}, } @@ -64,7 +64,7 @@ func TestStartCaptureSession(t *testing.T) { t.Run("success with config", func(t *testing.T) { svc := newTestService(t, newMockRecordManager()) - cats := []oapi.CaptureConfigCategories{oapi.Console} + cats := []oapi.CaptureConfigCategories{oapi.CaptureConfigCategoriesConsole} resp, err := svc.StartCaptureSession(ctx, oapi.StartCaptureSessionRequestObject{ Body: &oapi.StartCaptureSessionRequest{ Config: &oapi.CaptureConfig{Categories: &cats}, @@ -141,7 +141,7 @@ func TestUpdateCaptureSession(t *testing.T) { _, err := svc.StartCaptureSession(ctx, oapi.StartCaptureSessionRequestObject{}) require.NoError(t, err) - cats := []oapi.CaptureConfigCategories{oapi.Console} + cats := []oapi.CaptureConfigCategories{oapi.CaptureConfigCategoriesConsole} resp, err := svc.UpdateCaptureSession(ctx, oapi.UpdateCaptureSessionRequestObject{ Body: &oapi.UpdateCaptureSessionRequest{ Config: &oapi.CaptureConfig{Categories: &cats}, @@ -152,7 +152,7 @@ func TestUpdateCaptureSession(t *testing.T) { require.True(t, ok) require.NotNil(t, r200.Config.Categories) assert.Len(t, *r200.Config.Categories, 1) - assert.Equal(t, oapi.Console, (*r200.Config.Categories)[0]) + assert.Equal(t, oapi.CaptureConfigCategoriesConsole, (*r200.Config.Categories)[0]) }) t.Run("empty body is no-op", func(t *testing.T) { @@ -246,7 +246,8 @@ func (m *mockRecordManager) StopAll(_ context.Context) error // newTestService builds an ApiService with minimal dependencies for capture session tests. func newTestService(t *testing.T, mgr recorder.RecordManager) *ApiService { t.Helper() - svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + cs, es := newCaptureSession(t) + svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), cs, es, 0) require.NoError(t, err) svc.cdpMonitor = &stubCdpMonitor{} return svc diff --git a/server/cmd/api/api/display_test.go b/server/cmd/api/api/display_test.go index 905a8d72..1ecefa3c 100644 --- a/server/cmd/api/api/display_test.go +++ b/server/cmd/api/api/display_test.go @@ -34,7 +34,8 @@ func testFFmpegFactory(t *testing.T, tempDir string) recorder.FFmpegRecorderFact func newTestServiceWithFactory(t *testing.T, mgr recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService { t.Helper() - svc, err := New(mgr, factory, newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), newCaptureSession(t), 0) + cs, es := newCaptureSession(t) + svc, err := New(mgr, factory, newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), cs, es, 0) require.NoError(t, err) return svc } diff --git a/server/cmd/api/api/events.go b/server/cmd/api/api/events.go new file mode 100644 index 00000000..c72ba756 --- /dev/null +++ b/server/cmd/api/api/events.go @@ -0,0 +1,147 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/kernel/kernel-images/server/lib/events" + oapi "github.com/kernel/kernel-images/server/lib/oapi" +) + +// PublishEvent handles POST /events/publish. +// Injects a caller-supplied event into the event bus. Returns 400 if the event +// fails validation. +func (s *ApiService) PublishEvent(_ context.Context, req oapi.PublishEventRequestObject) (oapi.PublishEventResponseObject, error) { + body := req.Body + if body == nil || body.Type == "" { + return oapi.PublishEvent400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "type is required"}}, nil + } + ev := events.Event{Type: body.Type} + + ev.Ts = time.Now().UnixMicro() + if body.Category != nil { + cat := events.EventCategory(*body.Category) + if !events.ValidCategory(cat) { + return oapi.PublishEvent400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid category"}}, nil + } + ev.Category = cat + } else { + ev.Category = events.CategorySystem + } + + if body.Source != nil { + if body.Source.Kind != nil { + if *body.Source.Kind == oapi.KernelApi { + return oapi.PublishEvent400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "source.kind kernel_api is reserved for server-generated events"}}, nil + } + ev.Source.Kind = events.SourceKind(*body.Source.Kind) + } + if body.Source.Event != nil { + ev.Source.Event = *body.Source.Event + } + if body.Source.Metadata != nil { + ev.Source.Metadata = *body.Source.Metadata + } + } + + if body.Data != nil { + // re-marshal body.Data to normalize it into a canonical RawMessage byte slice. + data, err := json.Marshal(body.Data) + if err != nil { + return oapi.PublishEvent400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid data"}}, nil + } + ev.Data = json.RawMessage(data) + } + + env := s.eventStream.Publish(events.Envelope{Event: ev}) + return publishEventOKResponse{env}, nil +} + +// StreamEvents handles GET /events/stream. +// Opens an SSE stream of envelopes from the event bus ring buffer. +// Supports reconnection via the Last-Event-ID header. Emits a keepalive comment +// frame every 15 s when no event arrives. +func (s *ApiService) StreamEvents(ctx context.Context, req oapi.StreamEventsRequestObject) (oapi.StreamEventsResponseObject, error) { + // Default to the current seq so fresh connections only see new events. + // Seqs are process-monotonic; a Last-Event-ID from any prior session resumes correctly. + afterSeq := s.eventStream.Seq() + if id := req.Params.LastEventID; id != nil && *id != "" { + if n, err := strconv.ParseUint(*id, 10, 64); err == nil && n > 0 { + afterSeq = n + } + } + + reader := s.eventStream.NewReader(afterSeq) + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + for { + readCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + result, err := reader.Read(readCtx) + cancel() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + select { + case <-ctx.Done(): + return + default: + // No event in 15 s and client still connected, send keepalive. + if _, err := pw.Write([]byte(":\n\n")); err != nil { + return + } + continue + } + } + return + } + + if result.Dropped > 0 { + continue + } + + env := result.Envelope + if err := writeEnvelopeFrame(pw, &env.Seq, *env); err != nil { + return + } + } + }() + + headers := oapi.StreamEvents200ResponseHeaders{XSSEContentType: "application/json"} + return oapi.StreamEvents200TexteventStreamResponse{Body: pr, Headers: headers}, nil +} + +// publishEventOKResponse serializes events.Envelope directly so the response +// is identical in shape to the SSE stream frames. +type publishEventOKResponse struct{ env events.Envelope } + +func (r publishEventOKResponse) VisitPublishEventResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + return json.NewEncoder(w).Encode(r.env) +} + +// writeEnvelopeFrame writes a single SSE frame. If seq is non-nil it is +// emitted as the id: field, updating the client's Last-Event-ID. +func writeEnvelopeFrame(w io.Writer, seq *uint64, env events.Envelope) error { + data, err := json.Marshal(env) + if err != nil { + return err + } + var buf bytes.Buffer + if seq != nil { + fmt.Fprintf(&buf, "id: %d\n", *seq) + } + buf.WriteString("data: ") + buf.Write(data) + buf.WriteString("\n\n") + _, err = w.Write(buf.Bytes()) + return err +} diff --git a/server/cmd/api/api/events_test.go b/server/cmd/api/api/events_test.go new file mode 100644 index 00000000..b47cdffc --- /dev/null +++ b/server/cmd/api/api/events_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/events" + oapi "github.com/kernel/kernel-images/server/lib/oapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventLifecycle(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := newTestService(t, newMockRecordManager()) + + // Start a capture session. + startResp, err := svc.StartCaptureSession(ctx, oapi.StartCaptureSessionRequestObject{}) + require.NoError(t, err) + require.IsType(t, oapi.StartCaptureSession201JSONResponse{}, startResp) + + // Open an SSE stream (5s budget covers the three 2s selects below). + streamCtx, streamCancel := context.WithTimeout(ctx, 5*time.Second) + defer streamCancel() + streamResp, err := svc.StreamEvents(streamCtx, oapi.StreamEventsRequestObject{}) + require.NoError(t, err) + r200, ok := streamResp.(oapi.StreamEvents200TexteventStreamResponse) + require.True(t, ok) + + // Drain SSE frames into a channel. + received := make(chan events.Envelope, 4) + go func() { + defer close(received) + rd := bufio.NewReader(r200.Body) + for { + line, err := rd.ReadString('\n') + if err != nil { + return + } + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + var env events.Envelope + if err := json.Unmarshal([]byte(payload), &env); err != nil { + continue + } + received <- env + } + }() + + // Publish an event. + resp, err := svc.PublishEvent(ctx, oapi.PublishEventRequestObject{ + Body: &oapi.PublishEventRequest{Type: "test.event"}, + }) + require.NoError(t, err) + r200pub, ok := resp.(publishEventOKResponse) + require.True(t, ok, "expected 200 response") + assert.Equal(t, "test.event", r200pub.env.Event.Type) + assert.Greater(t, r200pub.env.Seq, uint64(0)) + + // Verify the published event arrives on the stream with the same seq. + select { + case env := <-received: + assert.Equal(t, "test.event", env.Event.Type) + assert.Equal(t, r200pub.env.Seq, env.Seq) + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for test.event") + } + + // Stop the session. + stopResp, err := svc.StopCaptureSession(ctx, oapi.StopCaptureSessionRequestObject{}) + require.NoError(t, err) + assert.IsType(t, oapi.StopCaptureSession200JSONResponse{}, stopResp) +} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 1b756648..48d17351 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -22,6 +22,7 @@ import ( serverpkg "github.com/kernel/kernel-images/server" "github.com/kernel/kernel-images/server/cmd/api/api" "github.com/kernel/kernel-images/server/cmd/config" + "github.com/kernel/kernel-images/server/lib/capturesession" "github.com/kernel/kernel-images/server/lib/chromedriverproxy" "github.com/kernel/kernel-images/server/lib/devtoolsproxy" "github.com/kernel/kernel-images/server/lib/events" @@ -92,14 +93,14 @@ func main() { } // Construct events pipeline - captureSession, err := events.NewCaptureSession(events.CaptureSessionConfig{ - LogDir: "/var/log/kernel", + eventStream, err := events.NewEventStream(events.EventStreamConfig{ RingCapacity: 1024, }) if err != nil { - slogger.Error("failed to create capture session", "err", err) + slogger.Error("failed to create event stream", "err", err) os.Exit(1) } + captureSession := capturesession.NewCaptureSession(eventStream) apiService, err := api.New( recorder.NewFFmpegManager(), @@ -108,6 +109,7 @@ func main() { stz, nekoAuthClient, captureSession, + eventStream, config.DisplayNum, ) if err != nil { diff --git a/server/lib/capturesession/capturesession.go b/server/lib/capturesession/capturesession.go new file mode 100644 index 00000000..9a7fa106 --- /dev/null +++ b/server/lib/capturesession/capturesession.go @@ -0,0 +1,154 @@ +package capturesession + +import ( + "sync" + "time" + + "github.com/kernel/kernel-images/server/lib/events" +) + +// CaptureConfig holds caller-supplied capture preferences. All fields are +// optional; zero values mean "use server defaults" (all categories). +type CaptureConfig struct { + // Categories limits which event categories are captured. + // nil or empty includes all categories. + Categories []events.EventCategory +} + +// CaptureSession manages a capture session against a shared EventStream. +// It is responsible for (a) category-filtering Publish calls, (b) tracking +// session-scoped metadata (ID, config, timestamps), and (c) embedding +// capture_session_id into Event.Data before forwarding to the bus. +type CaptureSession struct { + es *events.EventStream + mu sync.Mutex + captureSessionID string + sessionStartSeq uint64 + categories map[events.EventCategory]struct{} + createdAt time.Time +} + +func NewCaptureSession(es *events.EventStream) *CaptureSession { + cats := make(map[events.EventCategory]struct{}, len(events.AllCategories)) + for _, c := range events.AllCategories { + cats[c] = struct{}{} + } + return &CaptureSession{es: es, categories: cats} +} + +// Start begins a new capture session with the given ID and config. Sequence +// numbers are process-monotonic and do not reset between sessions; a +// Last-Event-ID from any previous session is valid for resuming the SSE stream. +func (s *CaptureSession) Start(captureSessionID string, cfg CaptureConfig) { + s.mu.Lock() + defer s.mu.Unlock() + s.captureSessionID = captureSessionID + s.sessionStartSeq = s.es.Seq() + s.createdAt = time.Now() + cats := cfg.Categories + if len(cats) == 0 { + cats = events.AllCategories + } + s.categories = make(map[events.EventCategory]struct{}, len(cats)) + for _, c := range cats { + s.categories[c] = struct{}{} + } +} + +// publishLocked stamps capture_session_id into ev.Source.Metadata and forwards to the bus. +// Requires s.mu to be held. +func (s *CaptureSession) publishLocked(ev events.Event) events.Envelope { + if ev.Ts == 0 { + ev.Ts = time.Now().UnixMicro() + } + if ev.Source.Metadata == nil { + ev.Source.Metadata = make(map[string]string) + } + ev.Source.Metadata["capture_session_id"] = s.captureSessionID + return s.es.Publish(events.Envelope{Event: ev}) +} + +// Publish applies the category filter then forwards ev to the EventStream. +func (s *CaptureSession) Publish(ev events.Event) { + s.mu.Lock() + defer s.mu.Unlock() + if s.captureSessionID == "" { + return + } + if _, ok := s.categories[ev.Category]; !ok { + return + } + s.publishLocked(ev) +} + +// NewReader returns a Reader from the EventStream positioned after afterSeq. +func (s *CaptureSession) NewReader(afterSeq uint64) *events.Reader { + return s.es.NewReader(afterSeq) +} + +// ID returns the current capture session ID, or "" if no session is active. +func (s *CaptureSession) ID() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.captureSessionID +} + +// Seq returns the sequence number of the last published event. +func (s *CaptureSession) Seq() uint64 { + return s.es.Seq() +} + +// SessionStartSeq returns the sequence number at which the current session +// started. Fresh SSE connections with no Last-Event-ID should begin here. +func (s *CaptureSession) SessionStartSeq() uint64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.sessionStartSeq +} + +// Config returns the current capture configuration. +func (s *CaptureSession) Config() CaptureConfig { + s.mu.Lock() + defer s.mu.Unlock() + cats := make([]events.EventCategory, 0, len(s.categories)) + for c := range s.categories { + cats = append(cats, c) + } + return CaptureConfig{Categories: cats} +} + +// CreatedAt returns when the current session was started. +func (s *CaptureSession) CreatedAt() time.Time { + s.mu.Lock() + defer s.mu.Unlock() + return s.createdAt +} + +// UpdateConfig applies a new CaptureConfig to the running session. +func (s *CaptureSession) UpdateConfig(cfg CaptureConfig) { + s.mu.Lock() + defer s.mu.Unlock() + cats := cfg.Categories + if len(cats) == 0 { + cats = events.AllCategories + } + s.categories = make(map[events.EventCategory]struct{}, len(cats)) + for _, c := range cats { + s.categories[c] = struct{}{} + } +} + +// Active reports whether a capture session is currently running. +func (s *CaptureSession) Active() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.captureSessionID != "" +} + +// Stop ends the current session. The ring buffer is left intact so existing +// readers can finish draining. +func (s *CaptureSession) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + s.captureSessionID = "" +} diff --git a/server/lib/capturesession/capturesession_test.go b/server/lib/capturesession/capturesession_test.go new file mode 100644 index 00000000..8f424e23 --- /dev/null +++ b/server/lib/capturesession/capturesession_test.go @@ -0,0 +1,207 @@ +package capturesession + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/events" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestEventStream(t *testing.T, capacity int) *events.EventStream { + t.Helper() + es, err := events.NewEventStream(events.EventStreamConfig{RingCapacity: capacity}) + require.NoError(t, err) + return es +} + +func newTestCaptureSession(t *testing.T) *CaptureSession { + t.Helper() + p := NewCaptureSession(newTestEventStream(t, 100)) + p.Start("test-session", CaptureConfig{}) + return p +} + +func readEnvelope(t *testing.T, r *events.Reader, ctx context.Context) events.Envelope { + t.Helper() + res, err := r.Read(ctx) + require.NoError(t, err) + require.NotNil(t, res.Envelope, "expected envelope, got drop") + return *res.Envelope +} + +func cdpEvent(typ string, cat events.EventCategory) events.Event { + return events.Event{Type: typ, Category: cat, Source: events.Source{Kind: events.KindCDP}} +} + +func sessionIDFromMetadata(t *testing.T, src events.Source) string { + t.Helper() + id, ok := src.Metadata["capture_session_id"] + require.True(t, ok, "capture_session_id not found in source.metadata") + return id +} + +func TestCaptureSession(t *testing.T) { + t.Run("concurrent_publish_seq_order", func(t *testing.T) { + const goroutines = 8 + const eventsEach = 50 + const total = goroutines * eventsEach + + p := NewCaptureSession(newTestEventStream(t, total)) + p.Start("test-concurrent", CaptureConfig{}) + reader := p.NewReader(0) + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < eventsEach; j++ { + p.Publish(cdpEvent("console.log", events.CategoryConsole)) + } + }() + } + wg.Wait() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + for want := uint64(1); want <= total; want++ { + env := readEnvelope(t, reader, ctx) + assert.Equal(t, want, env.Seq, "events must arrive in seq order") + } + }) + + t.Run("seq_continues_across_sessions", func(t *testing.T) { + p := NewCaptureSession(newTestEventStream(t, 100)) + p.Start("session-1", CaptureConfig{}) + p.Publish(cdpEvent("ev.one", events.CategorySystem)) + p.Publish(cdpEvent("ev.two", events.CategorySystem)) + + p.Start("session-2", CaptureConfig{}) + p.Publish(cdpEvent("ev.three", events.CategorySystem)) + + assert.Equal(t, uint64(2), p.SessionStartSeq(), "session-2 starts after seq 2") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + reader := p.NewReader(2) + env := readEnvelope(t, reader, ctx) + assert.Equal(t, uint64(3), env.Seq) + assert.Equal(t, "session-2", sessionIDFromMetadata(t, env.Event.Source)) + assert.Equal(t, "ev.three", env.Event.Type) + }) + + t.Run("publish_increments_seq", func(t *testing.T) { + p := newTestCaptureSession(t) + reader := p.NewReader(0) + + for i := 0; i < 3; i++ { + p.Publish(events.Event{Type: "page.navigation", Category: events.CategoryPage, Source: events.Source{Kind: events.KindCDP}, Ts: 1}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + for want := uint64(1); want <= 3; want++ { + env := readEnvelope(t, reader, ctx) + assert.Equal(t, want, env.Seq, "expected seq %d got %d", want, env.Seq) + } + }) + + t.Run("publish_sets_ts", func(t *testing.T) { + p := newTestCaptureSession(t) + reader := p.NewReader(0) + + before := time.Now().UnixMicro() + p.Publish(events.Event{Type: "page.navigation", Category: events.CategoryPage, Source: events.Source{Kind: events.KindCDP}}) + after := time.Now().UnixMicro() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + env := readEnvelope(t, reader, ctx) + assert.GreaterOrEqual(t, env.Event.Ts, before) + assert.LessOrEqual(t, env.Event.Ts, after) + }) + + t.Run("publish_writes_ring", func(t *testing.T) { + p := newTestCaptureSession(t) + + reader := p.NewReader(0) + p.Publish(events.Event{Type: "page.navigation", Category: events.CategoryPage, Source: events.Source{Kind: events.KindCDP}, Ts: 1}) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + env := readEnvelope(t, reader, ctx) + assert.Equal(t, "page.navigation", env.Event.Type) + assert.Equal(t, events.CategoryPage, env.Event.Category) + }) + + t.Run("start_sets_capture_session_id_in_source_metadata", func(t *testing.T) { + p := newTestCaptureSession(t) + p.Start("test-uuid", CaptureConfig{}) + + reader := p.NewReader(0) + p.Publish(events.Event{Type: "page.navigation", Category: events.CategoryPage, Source: events.Source{Kind: events.KindCDP}, Ts: 1}) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + env := readEnvelope(t, reader, ctx) + assert.Equal(t, "test-uuid", sessionIDFromMetadata(t, env.Event.Source)) + }) + + t.Run("data_unchanged_when_session_id_in_metadata", func(t *testing.T) { + p := newTestCaptureSession(t) + p.Start("merge-session", CaptureConfig{}) + + reader := p.NewReader(0) + p.Publish(events.Event{ + Type: "page.navigation", + Category: events.CategoryPage, + Source: events.Source{Kind: events.KindCDP}, + Ts: 1, + Data: json.RawMessage(`{"url":"https://example.com"}`), + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + env := readEnvelope(t, reader, ctx) + assert.Equal(t, "merge-session", sessionIDFromMetadata(t, env.Event.Source)) + assert.JSONEq(t, `{"url":"https://example.com"}`, string(env.Event.Data)) + }) + + t.Run("truncation_applied", func(t *testing.T) { + p := newTestCaptureSession(t) + reader := p.NewReader(0) + + largeData := strings.Repeat("x", 1_100_000) + rawData, err := json.Marshal(map[string]string{"payload": largeData}) + require.NoError(t, err) + + p.Publish(events.Event{ + Type: "page.navigation", + Category: events.CategoryPage, + Source: events.Source{Kind: events.KindCDP}, + Ts: 1, + Data: json.RawMessage(rawData), + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + env := readEnvelope(t, reader, ctx) + assert.True(t, env.Event.Truncated) + assert.True(t, json.Valid(env.Event.Data)) + }) +} + diff --git a/server/lib/events/capturesession.go b/server/lib/events/capturesession.go deleted file mode 100644 index a761d570..00000000 --- a/server/lib/events/capturesession.go +++ /dev/null @@ -1,187 +0,0 @@ -package events - -import ( - "fmt" - "log/slog" - "sync" - "time" -) - -// CaptureConfig holds caller-supplied capture preferences. All fields are -// optional; zero values mean "use server defaults" (all categories). -type CaptureConfig struct { - // Categories limits which event categories are captured - // nil represents all categories. - Categories []EventCategory -} - -// CaptureSession wraps events in envelopes and fans them out to a fileWriter -// Reusable: call Start with a new ID to begin a new session; call Stop to end -// the current session without closing the underlying writers. Close tears down -// file descriptors and should only be called during server shutdown. -type CaptureSession struct { - mu sync.Mutex - ring *ringBuffer - files *fileWriter - seq uint64 - captureSessionID string - categories map[EventCategory]struct{} - createdAt time.Time -} - -// CaptureSessionConfig holds the parameters for creating a CaptureSession. -type CaptureSessionConfig struct { - LogDir string - // RingCapacity is the number of envelopes the in-memory ring buffer holds. - RingCapacity int -} - -func NewCaptureSession(cfg CaptureSessionConfig) (*CaptureSession, error) { - rb, err := newRingBuffer(cfg.RingCapacity) - if err != nil { - return nil, fmt.Errorf("capture session: %w", err) - } - fw, err := newFileWriter(cfg.LogDir) - if err != nil { - return nil, fmt.Errorf("capture session: %w", err) - } - cats := make(map[EventCategory]struct{}, len(allCategories)) - for _, c := range allCategories { - cats[c] = struct{}{} - } - return &CaptureSession{ - ring: rb, - files: fw, - categories: cats, - }, nil -} - -// Start sets the capture session ID and applies the given config. It resets -// the sequence counter so each session starts at seq 1. -// The fileWriter is intentionally not rotated: events from different sessions -// are interleaved in the same per-category JSONL files and distinguished by -// their envelope's capture_session_id. -func (s *CaptureSession) Start(captureSessionID string, cfg CaptureConfig) { - s.mu.Lock() - defer s.mu.Unlock() - s.captureSessionID = captureSessionID - s.seq = 0 - s.createdAt = time.Now() - s.ring.reset() - cats := cfg.Categories - if len(cats) == 0 { - cats = allCategories - } - s.categories = make(map[EventCategory]struct{}, len(cats)) - for _, c := range cats { - s.categories[c] = struct{}{} - } -} - -// Publish wraps ev in an Envelope, truncates if needed, then writes to -// fileWriter (durable) before RingBuffer (in-memory fan-out). -func (s *CaptureSession) Publish(ev Event) { - s.mu.Lock() - defer s.mu.Unlock() - - // No active session, drop silently. This can happen when events - // arrive between Stop() and producers noticing, or before Start(). - if s.captureSessionID == "" { - return - } - - // Drop events whose category is outside the configured set. - if _, ok := s.categories[ev.Category]; !ok { - return - } - - if ev.Ts == 0 { - ev.Ts = time.Now().UnixMicro() - } - - s.seq++ - env := Envelope{ - CaptureSessionID: s.captureSessionID, - Seq: s.seq, - Event: ev, - } - env, data := truncateIfNeeded(env) - - if data == nil { - slog.Error("capture_session: marshal failed, skipping file write", "seq", env.Seq, "category", env.Event.Category) - } else { - filename := string(env.Event.Category) + ".log" - if err := s.files.Write(filename, data); err != nil { - slog.Error("capture_session: file write failed", "seq", env.Seq, "category", env.Event.Category, "err", err) - } - } - s.ring.publish(env) -} - -// NewReader returns a Reader positioned at the start of the ring buffer. -func (s *CaptureSession) NewReader(afterSeq uint64) *Reader { - return s.ring.newReader(afterSeq) -} - -// ID returns the current capture session ID, or "" if no session is active. -func (s *CaptureSession) ID() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.captureSessionID -} - -// Seq returns the current sequence number (last published). -func (s *CaptureSession) Seq() uint64 { - s.mu.Lock() - defer s.mu.Unlock() - return s.seq -} - -// Config returns the current capture configuration. -func (s *CaptureSession) Config() CaptureConfig { - s.mu.Lock() - defer s.mu.Unlock() - cats := make([]EventCategory, 0, len(s.categories)) - for c := range s.categories { - cats = append(cats, c) - } - return CaptureConfig{ - Categories: cats, - } -} - -// CreatedAt returns when the current session was started. -func (s *CaptureSession) CreatedAt() time.Time { - s.mu.Lock() - defer s.mu.Unlock() - return s.createdAt -} - -// UpdateConfig applies a new CaptureConfig to the running session without -// resetting the sequence counter or ring buffer. -func (s *CaptureSession) UpdateConfig(cfg CaptureConfig) { - s.mu.Lock() - defer s.mu.Unlock() - cats := cfg.Categories - if len(cats) == 0 { - cats = allCategories - } - s.categories = make(map[EventCategory]struct{}, len(cats)) - for _, c := range cats { - s.categories[c] = struct{}{} - } -} - -// Stop ends the current session by clearing the session ID. The ring buffer -// is intentionally left intact so existing readers can finish draining. -// A new session can be started by calling Start again. -func (s *CaptureSession) Stop() { - s.mu.Lock() - defer s.mu.Unlock() - s.captureSessionID = "" -} - -// Close flushes and releases all open file descriptors. -func (s *CaptureSession) Close() error { - return s.files.Close() -} diff --git a/server/lib/events/event.go b/server/lib/events/event.go index 1153168f..aa0e6b77 100644 --- a/server/lib/events/event.go +++ b/server/lib/events/event.go @@ -21,16 +21,15 @@ const ( CategorySystem EventCategory = "system" ) -// allCategories is the canonical list of all known event categories. -// Package-internal; treat as read-only. -var allCategories = []EventCategory{ +// AllCategories is the canonical list of all known event categories. +var AllCategories = []EventCategory{ CategoryConsole, CategoryNetwork, CategoryPage, CategoryInteraction, CategoryLiveview, CategoryCaptcha, CategorySystem, } var validCategories = func() map[EventCategory]struct{} { - m := make(map[EventCategory]struct{}, len(allCategories)) - for _, c := range allCategories { + m := make(map[EventCategory]struct{}, len(AllCategories)) + for _, c := range AllCategories { m[c] = struct{}{} } return m @@ -60,7 +59,7 @@ type Source struct { } // Event is the portable event schema. It contains only producer-emitted content; -// pipeline metadata (seq, capture session) lives on the Envelope. +// pipeline metadata (seq) lives on the Envelope. type Event struct { Ts int64 `json:"ts"` // Unix microseconds (µs since epoch) Type string `json:"type"` @@ -72,9 +71,8 @@ type Event struct { // Envelope wraps an Event with pipeline-assigned metadata. type Envelope struct { - CaptureSessionID string `json:"capture_session_id"` - Seq uint64 `json:"seq"` - Event Event `json:"event"` + Seq uint64 `json:"seq"` + Event Event `json:"event"` } // truncateIfNeeded marshals env and returns the (possibly truncated) envelope. diff --git a/server/lib/events/events_test.go b/server/lib/events/events_test.go index 5827c9bb..a7eea87b 100644 --- a/server/lib/events/events_test.go +++ b/server/lib/events/events_test.go @@ -1,12 +1,8 @@ package events import ( - "bytes" "context" "encoding/json" - "os" - "path/filepath" - "strings" "sync" "testing" "time" @@ -63,8 +59,7 @@ func TestEventSerialization(t *testing.T) { func TestEnvelopeSerialization(t *testing.T) { env := Envelope{ - CaptureSessionID: "test-session-id", - Seq: 1, + Seq: 1, Event: Event{ Ts: 1000, Type: "console.log", @@ -79,8 +74,8 @@ func TestEnvelopeSerialization(t *testing.T) { var decoded map[string]any require.NoError(t, json.Unmarshal(b, &decoded)) - assert.Equal(t, "test-session-id", decoded["capture_session_id"]) assert.Equal(t, float64(1), decoded["seq"]) + assert.NotContains(t, decoded, "capture_session_id") inner, ok := decoded["event"].(map[string]any) require.True(t, ok) assert.Equal(t, "console.log", inner["type"]) @@ -330,275 +325,6 @@ func TestConcurrentReaders(t *testing.T) { } } -// TestFileWriter: per-category JSONL appender tests. -func TestFileWriter(t *testing.T) { - t.Run("category_routing", func(t *testing.T) { - dir := t.TempDir() - fw, err := newFileWriter(dir) - require.NoError(t, err) - defer fw.Close() - - envsToFile := []struct { - env Envelope - file string - category string - }{ - {Envelope{Seq: 1, Event: Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}}, "console.log", "console"}, - {Envelope{Seq: 2, Event: Event{Type: "network.request", Category: CategoryNetwork, Source: Source{Kind: KindCDP}, Ts: 1}}, "network.log", "network"}, - {Envelope{Seq: 3, Event: Event{Type: "liveview.click", Category: CategoryLiveview, Source: Source{Kind: KindKernelAPI}, Ts: 1}}, "liveview.log", "liveview"}, - {Envelope{Seq: 4, Event: Event{Type: "captcha.solve", Category: CategoryCaptcha, Source: Source{Kind: KindExtension}, Ts: 1}}, "captcha.log", "captcha"}, - {Envelope{Seq: 5, Event: Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}}, "page.log", "page"}, - {Envelope{Seq: 6, Event: Event{Type: "input.click", Category: CategoryInteraction, Source: Source{Kind: KindCDP}, Ts: 1}}, "interaction.log", "interaction"}, - {Envelope{Seq: 7, Event: Event{Type: "monitor.connected", Category: CategorySystem, Source: Source{Kind: KindKernelAPI}, Ts: 1}}, "system.log", "system"}, - } - - for _, e := range envsToFile { - data, err := json.Marshal(e.env) - require.NoError(t, err) - require.NoError(t, fw.Write(e.file, data)) - } - - for _, e := range envsToFile { - data, err := os.ReadFile(filepath.Join(dir, e.file)) - require.NoError(t, err, "missing file %s for type %s", e.file, e.env.Event.Type) - - line := bytes.TrimRight(data, "\n") - require.True(t, json.Valid(line), "invalid JSON in %s", e.file) - - var decoded map[string]any - require.NoError(t, json.Unmarshal(line, &decoded)) - inner, ok := decoded["event"].(map[string]any) - require.True(t, ok) - assert.Equal(t, e.category, inner["category"], "wrong category in %s", e.file) - srcMap, ok := inner["source"].(map[string]any) - require.True(t, ok, "source should be an object in %s", e.file) - assert.Equal(t, string(e.env.Event.Source.Kind), srcMap["kind"], "wrong source kind in %s", e.file) - } - }) - - t.Run("empty_filename_rejected", func(t *testing.T) { - dir := t.TempDir() - fw, err := newFileWriter(dir) - require.NoError(t, err) - defer fw.Close() - - err = fw.Write("", []byte(`{"seq":1}`)) - require.Error(t, err) - assert.Contains(t, err.Error(), "empty filename") - }) - - t.Run("concurrent_writes", func(t *testing.T) { - dir := t.TempDir() - fw, err := newFileWriter(dir) - require.NoError(t, err) - defer fw.Close() - - const goroutines = 10 - const eventsPerGoroutine = 100 - - var wg sync.WaitGroup - for i := 0; i < goroutines; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - for j := 0; j < eventsPerGoroutine; j++ { - env := Envelope{ - Seq: uint64(i*eventsPerGoroutine + j), - Event: Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}, - } - envData, err := json.Marshal(env) - require.NoError(t, err) - require.NoError(t, fw.Write("console.log", envData)) - } - }(i) - } - wg.Wait() - - data, err := os.ReadFile(filepath.Join(dir, "console.log")) - require.NoError(t, err) - - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - assert.Len(t, lines, goroutines*eventsPerGoroutine) - for _, line := range lines { - assert.True(t, json.Valid([]byte(line)), "invalid JSON line: %s", line) - } - }) - - t.Run("lazy_open", func(t *testing.T) { - dir := t.TempDir() - fw, err := newFileWriter(dir) - require.NoError(t, err) - defer fw.Close() - - entries, err := os.ReadDir(dir) - require.NoError(t, err) - assert.Empty(t, entries, "files opened before first Write") - - env := Envelope{Seq: 1, Event: Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}} - envData, err := json.Marshal(env) - require.NoError(t, err) - require.NoError(t, fw.Write("console.log", envData)) - - entries, err = os.ReadDir(dir) - require.NoError(t, err) - assert.Len(t, entries, 1, "expected exactly one file after first Write") - assert.Equal(t, "console.log", entries[0].Name()) - }) -} - -func TestCaptureSession(t *testing.T) { - newCaptureSession := func(t *testing.T) (*CaptureSession, string) { - t.Helper() - dir := t.TempDir() - p, err := NewCaptureSession(CaptureSessionConfig{LogDir: dir, RingCapacity: 100}) - require.NoError(t, err) - p.Start("test-session", CaptureConfig{}) - t.Cleanup(func() { p.Close() }) - return p, dir - } - - t.Run("concurrent_publish_seq_order", func(t *testing.T) { - const goroutines = 8 - const eventsEach = 50 - const total = goroutines * eventsEach - - p, err := NewCaptureSession(CaptureSessionConfig{LogDir: t.TempDir(), RingCapacity: total}) - require.NoError(t, err) - p.Start("test-concurrent", CaptureConfig{}) - t.Cleanup(func() { p.Close() }) - reader := p.NewReader(0) - - var wg sync.WaitGroup - for i := 0; i < goroutines; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < eventsEach; j++ { - p.Publish(cdpEvent("console.log", CategoryConsole)) - } - }() - } - wg.Wait() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - for want := uint64(1); want <= total; want++ { - env := readEnvelope(t, reader, ctx) - assert.Equal(t, want, env.Seq, "events must arrive in seq order") - } - }) - - t.Run("publish_increments_seq", func(t *testing.T) { - p, _ := newCaptureSession(t) - reader := p.NewReader(0) - - for i := 0; i < 3; i++ { - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - for want := uint64(1); want <= 3; want++ { - env := readEnvelope(t, reader, ctx) - assert.Equal(t, want, env.Seq, "expected seq %d got %d", want, env.Seq) - } - }) - - t.Run("publish_sets_ts", func(t *testing.T) { - p, _ := newCaptureSession(t) - reader := p.NewReader(0) - - before := time.Now().UnixMicro() - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}}) - after := time.Now().UnixMicro() - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - env := readEnvelope(t, reader, ctx) - assert.GreaterOrEqual(t, env.Event.Ts, before) - assert.LessOrEqual(t, env.Event.Ts, after) - }) - - t.Run("publish_writes_file", func(t *testing.T) { - p, dir := newCaptureSession(t) - - p.Publish(Event{Type: "console.log", Category: CategoryConsole, Source: Source{Kind: KindCDP}, Ts: 1}) - - data, err := os.ReadFile(filepath.Join(dir, "console.log")) - require.NoError(t, err) - - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - require.Len(t, lines, 1) - assert.True(t, json.Valid([]byte(lines[0]))) - assert.Contains(t, lines[0], `"console.log"`) - }) - - t.Run("publish_writes_ring", func(t *testing.T) { - p, _ := newCaptureSession(t) - - reader := p.NewReader(0) - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - env := readEnvelope(t, reader, ctx) - assert.Equal(t, "page.navigation", env.Event.Type) - assert.Equal(t, CategoryPage, env.Event.Category) - }) - - t.Run("start_sets_capture_session_id", func(t *testing.T) { - p, _ := newCaptureSession(t) - p.Start("test-uuid", CaptureConfig{}) - - reader := p.NewReader(0) - p.Publish(Event{Type: "page.navigation", Category: CategoryPage, Source: Source{Kind: KindCDP}, Ts: 1}) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - env := readEnvelope(t, reader, ctx) - assert.Equal(t, "test-uuid", env.CaptureSessionID) - }) - - t.Run("truncation_applied", func(t *testing.T) { - p, dir := newCaptureSession(t) - reader := p.NewReader(0) - - largeData := strings.Repeat("x", 1_100_000) - rawData, err := json.Marshal(map[string]string{"payload": largeData}) - require.NoError(t, err) - - p.Publish(Event{ - Type: "page.navigation", - Category: CategoryPage, - Source: Source{Kind: KindCDP}, - Ts: 1, - Data: json.RawMessage(rawData), - }) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - env := readEnvelope(t, reader, ctx) - assert.True(t, env.Event.Truncated) - assert.True(t, json.Valid(env.Event.Data)) - - marshaled, err := json.Marshal(env) - require.NoError(t, err) - assert.LessOrEqual(t, len(marshaled), maxS2RecordBytes) - - data, err := os.ReadFile(filepath.Join(dir, "page.log")) - require.NoError(t, err) - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - require.Len(t, lines, 1) - assert.Contains(t, lines[0], `"truncated":true`) - }) - -} func TestRingBufferResetWithActiveReader(t *testing.T) { rb := newTestRingBuffer(t,10) diff --git a/server/lib/events/eventstream.go b/server/lib/events/eventstream.go new file mode 100644 index 00000000..371061d4 --- /dev/null +++ b/server/lib/events/eventstream.go @@ -0,0 +1,53 @@ +package events + +import ( + "fmt" + "sync" +) + +// EventStream is the process-lifetime event bus. It owns the ring buffer and +// sequence counter, which outlive individual capture sessions. +type EventStream struct { + mu sync.Mutex + seq uint64 + ring *ringBuffer +} + +type EventStreamConfig struct { + // RingCapacity is the number of envelopes the ring buffer holds. + RingCapacity int +} + +func NewEventStream(cfg EventStreamConfig) (*EventStream, error) { + rb, err := newRingBuffer(cfg.RingCapacity) + if err != nil { + return nil, fmt.Errorf("event stream: %w", err) + } + return &EventStream{ring: rb}, nil +} + +// Publish assigns a monotonically increasing seq to env, truncates oversized +// payloads, and pushes it to the ring buffer. +func (es *EventStream) Publish(env Envelope) Envelope { + es.mu.Lock() + es.seq++ + env.Seq = es.seq + es.mu.Unlock() + + env, _ = truncateIfNeeded(env) + es.ring.publish(env) + return env +} + +// NewReader returns a Reader positioned after afterSeq. Pass 0 to start from +// the oldest buffered event. +func (es *EventStream) NewReader(afterSeq uint64) *Reader { + return es.ring.newReader(afterSeq) +} + +// Seq returns the sequence number of the last published event. +func (es *EventStream) Seq() uint64 { + es.mu.Lock() + defer es.mu.Unlock() + return es.seq +} diff --git a/server/lib/events/filewriter.go b/server/lib/events/filewriter.go deleted file mode 100644 index d57002f1..00000000 --- a/server/lib/events/filewriter.go +++ /dev/null @@ -1,70 +0,0 @@ -package events - -import ( - "fmt" - "os" - "path/filepath" - "sync" -) - -// fileWriter is a JSONL appender keyed by filename. It opens each file lazily -// on first write (O_APPEND|O_CREATE|O_WRONLY) and serialises all concurrent -// writes with a single mutex. -type fileWriter struct { - mu sync.Mutex - files map[string]*os.File - dir string -} - -// newFileWriter returns a fileWriter that writes to dir, creating it if needed. -func newFileWriter(dir string) (*fileWriter, error) { - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("filewriter: create dir %s: %w", dir, err) - } - return &fileWriter{dir: dir, files: make(map[string]*os.File)}, nil -} - -// Write appends data as a single JSONL line to the named file under the -// writer's directory. -func (fw *fileWriter) Write(filename string, data []byte) error { - if filename == "" { - return fmt.Errorf("filewriter: empty filename") - } - - fw.mu.Lock() - defer fw.mu.Unlock() - - f, ok := fw.files[filename] - if !ok { - path := filepath.Join(fw.dir, filename) - var err error - f, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return fmt.Errorf("filewriter: open %s: %w", path, err) - } - fw.files[filename] = f - } - - if _, err := f.Write(data); err != nil { - return fmt.Errorf("filewriter: write: %w", err) - } - if _, err := f.Write([]byte{'\n'}); err != nil { - return fmt.Errorf("filewriter: write newline: %w", err) - } - - return nil -} - -// Close closes all open log file descriptors -func (fw *fileWriter) Close() error { - fw.mu.Lock() - defer fw.mu.Unlock() - - var firstErr error - for _, f := range fw.files { - if err := f.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr -} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index b81f8905..28ae96f3 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -28,31 +28,31 @@ import ( // Defines values for CaptureConfigCategories. const ( - Captcha CaptureConfigCategories = "captcha" - Console CaptureConfigCategories = "console" - Interaction CaptureConfigCategories = "interaction" - Liveview CaptureConfigCategories = "liveview" - Network CaptureConfigCategories = "network" - Page CaptureConfigCategories = "page" - System CaptureConfigCategories = "system" + CaptureConfigCategoriesCaptcha CaptureConfigCategories = "captcha" + CaptureConfigCategoriesConsole CaptureConfigCategories = "console" + CaptureConfigCategoriesInteraction CaptureConfigCategories = "interaction" + CaptureConfigCategoriesLiveview CaptureConfigCategories = "liveview" + CaptureConfigCategoriesNetwork CaptureConfigCategories = "network" + CaptureConfigCategoriesPage CaptureConfigCategories = "page" + CaptureConfigCategoriesSystem CaptureConfigCategories = "system" ) // Valid indicates whether the value is a known member of the CaptureConfigCategories enum. func (e CaptureConfigCategories) Valid() bool { switch e { - case Captcha: + case CaptureConfigCategoriesCaptcha: return true - case Console: + case CaptureConfigCategoriesConsole: return true - case Interaction: + case CaptureConfigCategoriesInteraction: return true - case Liveview: + case CaptureConfigCategoriesLiveview: return true - case Network: + case CaptureConfigCategoriesNetwork: return true - case Page: + case CaptureConfigCategoriesPage: return true - case System: + case CaptureConfigCategoriesSystem: return true default: return false @@ -182,6 +182,63 @@ func (e DragMouseRequestButton) Valid() bool { } } +// Defines values for EventCategory. +const ( + EventCategoryCaptcha EventCategory = "captcha" + EventCategoryConsole EventCategory = "console" + EventCategoryInteraction EventCategory = "interaction" + EventCategoryLiveview EventCategory = "liveview" + EventCategoryNetwork EventCategory = "network" + EventCategoryPage EventCategory = "page" + EventCategorySystem EventCategory = "system" +) + +// Valid indicates whether the value is a known member of the EventCategory enum. +func (e EventCategory) Valid() bool { + switch e { + case EventCategoryCaptcha: + return true + case EventCategoryConsole: + return true + case EventCategoryInteraction: + return true + case EventCategoryLiveview: + return true + case EventCategoryNetwork: + return true + case EventCategoryPage: + return true + case EventCategorySystem: + return true + default: + return false + } +} + +// Defines values for EventSourceKind. +const ( + Cdp EventSourceKind = "cdp" + Extension EventSourceKind = "extension" + KernelApi EventSourceKind = "kernel_api" + LocalProcess EventSourceKind = "local_process" +) + +// Valid indicates whether the value is a known member of the EventSourceKind enum. +func (e EventSourceKind) Valid() bool { + switch e { + case Cdp: + return true + case Extension: + return true + case KernelApi: + return true + case LocalProcess: + return true + default: + return false + } +} + // Defines values for FileSystemEventType. const ( CREATE FileSystemEventType = "CREATE" @@ -305,6 +362,39 @@ func (e ProcessStreamEventStream) Valid() bool { } } +// Defines values for PublishEventRequestCategory. +const ( + Captcha PublishEventRequestCategory = "captcha" + Console PublishEventRequestCategory = "console" + Interaction PublishEventRequestCategory = "interaction" + Liveview PublishEventRequestCategory = "liveview" + Network PublishEventRequestCategory = "network" + Page PublishEventRequestCategory = "page" + System PublishEventRequestCategory = "system" +) + +// Valid indicates whether the value is a known member of the PublishEventRequestCategory enum. +func (e PublishEventRequestCategory) Valid() bool { + switch e { + case Captcha: + return true + case Console: + return true + case Interaction: + return true + case Liveview: + return true + case Network: + return true + case Page: + return true + case System: + return true + default: + return false + } +} + // Defines values for DownloadDirZstdParamsCompressionLevel. const ( Best DownloadDirZstdParamsCompressionLevel = "best" @@ -369,7 +459,7 @@ type CaptureSession struct { CreatedAt time.Time `json:"created_at"` Id string `json:"id"` - // Seq Monotonically increasing sequence number (last published). + // Seq Process-monotonic sequence number of the last published event. Does not reset between sessions. Seq int64 `json:"seq"` Status CaptureSessionStatus `json:"status"` } @@ -500,6 +590,42 @@ type Error struct { Message string `json:"message"` } +// Event A capture event. +type Event struct { + // Category Event category. + Category *EventCategory `json:"category,omitempty"` + + // Data Arbitrary event payload. + Data interface{} `json:"data,omitempty"` + + // Source Provenance of the event. + Source *EventSource `json:"source,omitempty"` + + // Truncated Set by the server when the data field was truncated to fit the size limit. + Truncated *bool `json:"truncated,omitempty"` + + // Ts Unix timestamp in microseconds. Defaults to the current time when omitted. + Ts *int64 `json:"ts,omitempty"` + + // Type Event type identifier. + Type string `json:"type"` +} + +// EventCategory Event category. +type EventCategory string + +// EventSource Provenance of the event. +type EventSource struct { + Event *string `json:"event,omitempty"` + + // Kind Source kind. "kernel_api" is reserved for server-generated events. + Kind *EventSourceKind `json:"kind,omitempty"` + Metadata *map[string]string `json:"metadata,omitempty"` +} + +// EventSourceKind Source kind. "kernel_api" is reserved for server-generated events. +type EventSourceKind string + // ExecutePlaywrightRequest Request to execute Playwright code type ExecutePlaywrightRequest struct { // Code TypeScript/JavaScript code to execute. The code has access to 'page', 'context', and 'browser' variables. @@ -809,6 +935,33 @@ type ProcessStreamEventEvent string // ProcessStreamEventStream Source stream of the data chunk. type ProcessStreamEventStream string +// PublishEventRequest Request body for publishing an event into the event bus. +type PublishEventRequest struct { + // Category Event category. + Category *PublishEventRequestCategory `json:"category,omitempty"` + + // Data Capture Session Event Payload + Data interface{} `json:"data,omitempty"` + + // Source Provenance of the event. + Source *EventSource `json:"source,omitempty"` + + // Type Event type identifier. + Type string `json:"type"` +} + +// PublishEventRequestCategory Event category. +type PublishEventRequestCategory string + +// PublishedEnvelope The envelope assigned to a successfully published event. +type PublishedEnvelope struct { + // Event A capture event. + Event Event `json:"event"` + + // Seq Process-monotonic sequence number assigned across the lifetime of the server. Use with Last-Event-ID to resume the SSE stream from this point. + Seq int64 `json:"seq"` +} + // RecorderInfo defines model for RecorderInfo. type RecorderInfo struct { // FinishedAt Timestamp when recording finished @@ -990,6 +1143,12 @@ type UploadExtensionsAndRestartMultipartBody struct { } `json:"extensions"` } +// StreamEventsParams defines parameters for StreamEvents. +type StreamEventsParams struct { + // LastEventID Resume after this sequence number. Omit or send 0 to start from the current position. Sequence numbers are process-monotonic, so a value from a previous session resumes correctly from that point. + LastEventID *string `json:"Last-Event-ID,omitempty"` +} + // DownloadDirZipParams defines parameters for DownloadDirZip. type DownloadDirZipParams struct { // Path Absolute directory path to archive and download. @@ -1136,6 +1295,9 @@ type UpdateCaptureSessionJSONRequestBody = UpdateCaptureSessionRequest // StartCaptureSessionJSONRequestBody defines body for StartCaptureSession for application/json ContentType. type StartCaptureSessionJSONRequestBody = StartCaptureSessionRequest +// PublishEventJSONRequestBody defines body for PublishEvent for application/json ContentType. +type PublishEventJSONRequestBody = PublishEventRequest + // CreateDirectoryJSONRequestBody defines body for CreateDirectory for application/json ContentType. type CreateDirectoryJSONRequestBody = CreateDirectoryRequest @@ -1353,6 +1515,14 @@ type ClientInterface interface { StartCaptureSession(ctx context.Context, body StartCaptureSessionJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // PublishEventWithBody request with any body + PublishEventWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PublishEvent(ctx context.Context, body PublishEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // StreamEvents request + StreamEvents(ctx context.Context, params *StreamEventsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateDirectoryWithBody request with any body CreateDirectoryWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -1897,6 +2067,42 @@ func (c *Client) StartCaptureSession(ctx context.Context, body StartCaptureSessi return c.Client.Do(req) } +func (c *Client) PublishEventWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPublishEventRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PublishEvent(ctx context.Context, body PublishEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPublishEventRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) StreamEvents(ctx context.Context, params *StreamEventsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewStreamEventsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) CreateDirectoryWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewCreateDirectoryRequestWithBody(c.Server, contentType, body) if err != nil { @@ -3186,6 +3392,88 @@ func NewStartCaptureSessionRequestWithBody(server string, contentType string, bo return req, nil } +// NewPublishEventRequest calls the generic PublishEvent builder with application/json body +func NewPublishEventRequest(server string, body PublishEventJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPublishEventRequestWithBody(server, "application/json", bodyReader) +} + +// NewPublishEventRequestWithBody generates requests for PublishEvent with any type of body +func NewPublishEventRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/events/publish") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewStreamEventsRequest generates requests for StreamEvents +func NewStreamEventsRequest(server string, params *StreamEventsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/events/stream") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + if params != nil { + + if params.LastEventID != nil { + var headerParam0 string + + headerParam0, err = runtime.StyleParamWithOptions("simple", false, "Last-Event-ID", *params.LastEventID, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + req.Header.Set("Last-Event-ID", headerParam0) + } + + } + + return req, nil +} + // NewCreateDirectoryRequest calls the generic CreateDirectory builder with application/json body func NewCreateDirectoryRequest(server string, body CreateDirectoryJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -4636,6 +4924,14 @@ type ClientWithResponsesInterface interface { StartCaptureSessionWithResponse(ctx context.Context, body StartCaptureSessionJSONRequestBody, reqEditors ...RequestEditorFn) (*StartCaptureSessionResponse, error) + // PublishEventWithBodyWithResponse request with any body + PublishEventWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PublishEventResponse, error) + + PublishEventWithResponse(ctx context.Context, body PublishEventJSONRequestBody, reqEditors ...RequestEditorFn) (*PublishEventResponse, error) + + // StreamEventsWithResponse request + StreamEventsWithResponse(ctx context.Context, params *StreamEventsParams, reqEditors ...RequestEditorFn) (*StreamEventsResponse, error) + // CreateDirectoryWithBodyWithResponse request with any body CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) @@ -5226,6 +5522,50 @@ func (r StartCaptureSessionResponse) StatusCode() int { return 0 } +type PublishEventResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PublishedEnvelope + JSON400 *BadRequestError +} + +// Status returns HTTPResponse.Status +func (r PublishEventResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PublishEventResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type StreamEventsResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// Status returns HTTPResponse.Status +func (r StreamEventsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r StreamEventsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type CreateDirectoryResponse struct { Body []byte HTTPResponse *http.Response @@ -6275,6 +6615,32 @@ func (c *ClientWithResponses) StartCaptureSessionWithResponse(ctx context.Contex return ParseStartCaptureSessionResponse(rsp) } +// PublishEventWithBodyWithResponse request with arbitrary body returning *PublishEventResponse +func (c *ClientWithResponses) PublishEventWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PublishEventResponse, error) { + rsp, err := c.PublishEventWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePublishEventResponse(rsp) +} + +func (c *ClientWithResponses) PublishEventWithResponse(ctx context.Context, body PublishEventJSONRequestBody, reqEditors ...RequestEditorFn) (*PublishEventResponse, error) { + rsp, err := c.PublishEvent(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePublishEventResponse(rsp) +} + +// StreamEventsWithResponse request returning *StreamEventsResponse +func (c *ClientWithResponses) StreamEventsWithResponse(ctx context.Context, params *StreamEventsParams, reqEditors ...RequestEditorFn) (*StreamEventsResponse, error) { + rsp, err := c.StreamEvents(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseStreamEventsResponse(rsp) +} + // CreateDirectoryWithBodyWithResponse request with arbitrary body returning *CreateDirectoryResponse func (c *ClientWithResponses) CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) { rsp, err := c.CreateDirectoryWithBody(ctx, contentType, body, reqEditors...) @@ -7376,6 +7742,55 @@ func ParseStartCaptureSessionResponse(rsp *http.Response) (*StartCaptureSessionR return response, nil } +// ParsePublishEventResponse parses an HTTP response from a PublishEventWithResponse call +func ParsePublishEventResponse(rsp *http.Response) (*PublishEventResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PublishEventResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PublishedEnvelope + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + } + + return response, nil +} + +// ParseStreamEventsResponse parses an HTTP response from a StreamEventsWithResponse call +func ParseStreamEventsResponse(rsp *http.Response) (*StreamEventsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &StreamEventsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + // ParseCreateDirectoryResponse parses an HTTP response from a CreateDirectoryWithResponse call func ParseCreateDirectoryResponse(rsp *http.Response) (*CreateDirectoryResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -8692,6 +9107,12 @@ type ServerInterface interface { // Start the capture session // (POST /events/capture_session) StartCaptureSession(w http.ResponseWriter, r *http.Request) + // Publish an event into the event bus + // (POST /events/publish) + PublishEvent(w http.ResponseWriter, r *http.Request) + // Stream events as Server-Sent Events + // (GET /events/stream) + StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams) // Create a new directory // (PUT /fs/create_directory) CreateDirectory(w http.ResponseWriter, r *http.Request) @@ -8911,6 +9332,18 @@ func (_ Unimplemented) StartCaptureSession(w http.ResponseWriter, r *http.Reques w.WriteHeader(http.StatusNotImplemented) } +// Publish an event into the event bus +// (POST /events/publish) +func (_ Unimplemented) PublishEvent(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Stream events as Server-Sent Events +// (GET /events/stream) +func (_ Unimplemented) StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams) { + w.WriteHeader(http.StatusNotImplemented) +} + // Create a new directory // (PUT /fs/create_directory) func (_ Unimplemented) CreateDirectory(w http.ResponseWriter, r *http.Request) { @@ -9386,6 +9819,60 @@ func (siw *ServerInterfaceWrapper) StartCaptureSession(w http.ResponseWriter, r handler.ServeHTTP(w, r) } +// PublishEvent operation middleware +func (siw *ServerInterfaceWrapper) PublishEvent(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PublishEvent(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StreamEvents operation middleware +func (siw *ServerInterfaceWrapper) StreamEvents(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params StreamEventsParams + + headers := r.Header + + // ------------- Optional header parameter "Last-Event-ID" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("Last-Event-ID")]; found { + var LastEventID string + n := len(valueList) + if n != 1 { + siw.ErrorHandlerFunc(w, r, &TooManyValuesForParamError{ParamName: "Last-Event-ID", Count: n}) + return + } + + err = runtime.BindStyledParameterWithOptions("simple", "Last-Event-ID", valueList[0], &LastEventID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: false, Required: false, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "Last-Event-ID", Err: err}) + return + } + + params.LastEventID = &LastEventID + + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StreamEvents(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // CreateDirectory operation middleware func (siw *ServerInterfaceWrapper) CreateDirectory(w http.ResponseWriter, r *http.Request) { @@ -10263,6 +10750,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/events/capture_session", wrapper.StartCaptureSession) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/events/publish", wrapper.PublishEvent) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/events/stream", wrapper.StreamEvents) + }) r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/fs/create_directory", wrapper.CreateDirectory) }) @@ -11045,6 +11538,89 @@ func (response StartCaptureSession500JSONResponse) VisitStartCaptureSessionRespo return json.NewEncoder(w).Encode(response) } +type PublishEventRequestObject struct { + Body *PublishEventJSONRequestBody +} + +type PublishEventResponseObject interface { + VisitPublishEventResponse(w http.ResponseWriter) error +} + +type PublishEvent200JSONResponse PublishedEnvelope + +func (response PublishEvent200JSONResponse) VisitPublishEventResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type PublishEvent400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response PublishEvent400JSONResponse) VisitPublishEventResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type StreamEventsRequestObject struct { + Params StreamEventsParams +} + +type StreamEventsResponseObject interface { + VisitStreamEventsResponse(w http.ResponseWriter) error +} + +type StreamEvents200ResponseHeaders struct { + XSSEContentType string +} + +type StreamEvents200TexteventStreamResponse struct { + Body io.Reader + Headers StreamEvents200ResponseHeaders + ContentLength int64 +} + +func (response StreamEvents200TexteventStreamResponse) VisitStreamEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "text/event-stream") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.Header().Set("X-SSE-Content-Type", fmt.Sprint(response.Headers.XSSEContentType)) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + flusher, ok := w.(http.Flusher) + if !ok { + // If w doesn't support flushing, might as well use io.Copy + _, err := io.Copy(w, response.Body) + return err + } + + // Use a buffer for efficient copying and flushing + buf := make([]byte, 4096) // text/event-stream are usually very small messages + for { + n, err := response.Body.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return werr + } + flusher.Flush() // Flush after each write + } + if err != nil { + if err == io.EOF { + return nil // End of file, no error + } + return err + } + } +} + type CreateDirectoryRequestObject struct { Body *CreateDirectoryJSONRequestBody } @@ -11744,6 +12320,8 @@ func (response StreamFsEvents200TexteventStreamResponse) VisitStreamFsEventsResp w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) } w.Header().Set("X-SSE-Content-Type", fmt.Sprint(response.Headers.XSSEContentType)) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") w.WriteHeader(200) if closer, ok := response.Body.(io.ReadCloser); ok { @@ -11870,6 +12448,8 @@ func (response LogsStream200TexteventStreamResponse) VisitLogsStreamResponse(w h w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) } w.Header().Set("X-SSE-Content-Type", fmt.Sprint(response.Headers.XSSEContentType)) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") w.WriteHeader(200) if closer, ok := response.Body.(io.ReadCloser); ok { @@ -12209,6 +12789,8 @@ func (response ProcessStdoutStream200TexteventStreamResponse) VisitProcessStdout w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) } w.Header().Set("X-SSE-Content-Type", fmt.Sprint(response.Headers.XSSEContentType)) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") w.WriteHeader(200) if closer, ok := response.Body.(io.ReadCloser); ok { @@ -12559,6 +13141,12 @@ type StrictServerInterface interface { // Start the capture session // (POST /events/capture_session) StartCaptureSession(ctx context.Context, request StartCaptureSessionRequestObject) (StartCaptureSessionResponseObject, error) + // Publish an event into the event bus + // (POST /events/publish) + PublishEvent(ctx context.Context, request PublishEventRequestObject) (PublishEventResponseObject, error) + // Stream events as Server-Sent Events + // (GET /events/stream) + StreamEvents(ctx context.Context, request StreamEventsRequestObject) (StreamEventsResponseObject, error) // Create a new directory // (PUT /fs/create_directory) CreateDirectory(ctx context.Context, request CreateDirectoryRequestObject) (CreateDirectoryResponseObject, error) @@ -13281,6 +13869,63 @@ func (sh *strictHandler) StartCaptureSession(w http.ResponseWriter, r *http.Requ } } +// PublishEvent operation middleware +func (sh *strictHandler) PublishEvent(w http.ResponseWriter, r *http.Request) { + var request PublishEventRequestObject + + var body PublishEventJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.PublishEvent(ctx, request.(PublishEventRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "PublishEvent") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(PublishEventResponseObject); ok { + if err := validResponse.VisitPublishEventResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// StreamEvents operation middleware +func (sh *strictHandler) StreamEvents(w http.ResponseWriter, r *http.Request, params StreamEventsParams) { + var request StreamEventsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.StreamEvents(ctx, request.(StreamEventsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "StreamEvents") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(StreamEventsResponseObject); ok { + if err := validResponse.VisitStreamEventsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // CreateDirectory operation middleware func (sh *strictHandler) CreateDirectory(w http.ResponseWriter, r *http.Request) { var request CreateDirectoryRequestObject @@ -14195,169 +14840,184 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+x9+XMbN9bgv4Lq/aos7ZAUfWU2nvp+UGw50SaOVZa9mUno5YDdjyQ+dQM9AJoS7fL8", - "7Vt4APpE85LkI/tVuRKKxPkuPDy842MUiywXHLhW0bOPkQSVC64A//iBJm/gXwUofSalkOarWHANXJuP", - "NM9TFlPNBD/5LyW4+U7FS8io+fQfEubRs+h/nFTjn9hf1Ykd7dOnT4MoARVLlptBomdmQuJmjD4NoueC", - "z1MWf67Z/XRm6nOuQXKafqap/XTkEuQKJHENB9GvQr8UBU8+0zp+FZrgfJH5zTW3pKDj5XOR5YUGeRqb", - "5h5RZiVJwsxXNL2QIgepmSGgOU0VtGc4JTMzFBFzErvhCMXxFNGCwA3EhQaizOBcM5qm61E0iPLauB8j", - "18F8bI7+WiYgISEpU9pM0R15RM7wAxOcKC1yRQQneglkzqTSBAxkzIRMQ6a2wbEJEIOvjPFz2/PhINLr", - "HKJnEZWSrhGgEv5VMAlJ9OyPcg/vy3Zi9l9gqe85zXUhwRAkW+wJYNeXzFmqQTK+ILmEOUjgMaguKGOq", - "YSGk+6s51NkKuCZVCwPG2A4/Ir8tgRORMa0hIUISyHK9HhCapvUeVILvkowmvA5Y4EVmABELrkQK0SDi", - "oK+FvDJrpAvzBTNsYQEVDaKUrWDF4DoaRGbIeEmjQaTWSkNWg6LSZtMGih3w98H5EpRiln/2omS3MaJs", - "fyJBiULGEIByicmN5NRA+6dBFEugGpIpRS6bC5mZT1FCNQw1ywyIOrtmiWnb+VrBv7oIfiW40IKz2LAZ", - "YdzMpwzJWPaLgfAim4EkRylVmuTFLGVqCcmx2WG5HMb1d08ipH2WGZyOy2UZBC4AJZnSVBcNxMuCc7M6", - "85vIc0gCWGwxDUuicqSBh6rdXANaQaZKWXz1ShQKdpVcTRzOCq0tkbSBWCgg9lfDIZ5oyTXTy2hQbjeF", - "uY4GkWSLpUZoJQlS/YzGVxac11QmQUqOzdKn9uv29G/XOaA0NW1IySx+1kRcmz+LPHLDBCdYijSZXsFa", - "hbaXsDkDSczPZn+mLUkKFC1GcNpRa4y9hREHES+yKfZy081pkWqUmK3TyBKfmBND7Di5hByobszbJbWb", - "7i7+TmIhZMI41QitcgCSC8UczLojrbsj/eOQkVpkfBOZoXuINJ8JKpPntXN+dxrVcKO7S35eSImS3A9O", - "TDviVYltTIeDBhfbPP72FZ9G0qTQVgPqWgBVJKfSnuRWbxiRt0sg/zRL+SeZM0gToiCFWCtyvWTxcsKr", - "UXKQRkYNCOWJRZOQVr9NDO3a3gYIlBkVYQl+BTmVNAMNUo0m/OyGxjpdE8HL323PzKzHM4FZEMkKpckM", - "SC7FiiX+wGudBMjKmZEZW4+DjsAy+pqki926v5B00e6diRXs1vuVWEG7dy5BKSMmtnW+MA1/hnWtr4ql", - "SNNtHS+xVb0b6GlcSGWV341dQT/HhvXeKUC+taNpVGlwPVLW47hUKmsUNqrJ2zp+G/C2I0+RmeqgLEHT", - "wG1j534jfUrO1LP9pm2ac+It3OgSPG0uNyMHuRyP1RdMQqyFXB92eGYiCUD1dW67k8SPTkxDciRiTVNi", - "dzkgMFqMyF+fPj0ekRf2sMCz4K9Pn6KmRbW5PEXPov/7x3j41/cfHw+efPqPkGqUU73sLuJ0pkRqpE21", - "CNMQlV3cemuSk9H/3CoycaYQMF9AChouqF4eBsctW/ALT3Cau1/4G4jx7Fsctnqrm7auvom556GG4U5T", - "6Sep7YScpvmS8iIDyWJz3Viu8yXwNv7p8MPp8Pfx8Pvh+7/8R3Cz3Y0xlad0veNdq7mfJaAy13vgJnZs", - "YtsRxknObiBVQV1DwlyCWk4l1bB9SNeamNZm4J8+kKOMrs3xw4s0JWxOuNAkAQ2xprMUjoOTXrMkRFDt", - "2bDZxvUHQds+ge5H4TZis0fZLpVsq3WHBGgCKV039NBxW1V5YZqY3WcsTZmCWPBEkRnoawDuF2IUbdQ0", - "lKZSO+o18p/QVDgtwXDXaOtNKSkkGnWmWUAdf0vlAjTRwghI37KztrmQOKFhLQkWQmYtmUHqtbm5q0wI", - "vfxPLQsYkdcZ09iHFlpkVLPYaNxmDzOqzPWeuwlRvqTAF24f9Mbu4+F4PB7X9vU0uLHb3DLMFva6ZIQl", - "ZdtA9MfNgKzf11X6nDKpStzppRTFYmmUy9QuYsH4YkReGVXP6Y6EapKCuSA/IrlgXKuGAam95BpAMnrj", - "rEWP6qajR93dbPzR4rJBwwavbTJ+p4Asi4zyYcqugPwAHwzA40KuoKJmxPA1XduNEMaVBpoYUKWMA5X2", - "epuLFAnPmYFwNqI05Gqag5wqWCClWXaAfIpMNs2sPYgtuJCQjCopMhMiBcqtmaDWvLGlp3vypQSzxhXY", - "dXUweG5X0eWGHSwZrX02b7Hj/mtsuSSkLbuuHCTx8GK8EhP9CySv7PLIw8ZaH269dvYe7qV1uaW0gVJ0", - "AQF2aw3sGwbHtne5i5Sur1EKH2Y3dr3qt8NqSBIb9bJrdEt67CWX+PfJ/6Yraj/iAA0r8Vu8LyZAllQR", - "GsegUCg8yOkCHgzIA7w83+gH9nb5YCbFtQL5gKyoZOa4dVfHLE/hGZlE9JoyTUzn0UJocfRgqXWunp2c", - "gG0zikX24PhvRIIuJCe15prpFI6O/zaJJjyk1GqWgSj0VEHcoMPvOnT4ykprt0e8wrAMlQfHOqV6TZgi", - "340bEv5xQ75vpzUE/o70oHDBe5KD6WR4qkUF1e469ACeyltWbvM1cSRs9KYKPnPKUkhCUJflorv3xBVN", - "C3CYhITM1s74YC42bE4oXx9bMZKADKznUlOeUJnYtwgylyKzZ3ltY13zrk5EoTcMJgqdF3rX0Qok+O5w", - "vy1BL0FWG3L8khDXZV6k6Tog2FvU4ScIEchLlsI5n4uuPGJqmjC5eVV4fjFFaHWdCx80mUimaEPvDPeL", - "OcozVEjs6xryScPkvdECH77rmm3Z2+2MaUWOzKV2QCZRIq9v5ND8m0TmYjOJhvJ6KIfm3yQ6HoVm4DS0", - "7h+oAmJ+8hepuZlSyCAkdr4Ve521SyTsA0xnax16ObpkH1Cw4M8jMkbl0i+D2XeoLQIE9+hW15hs4Omg", - "hkMH9D5yusRHInzOCiPGviKReEn5AgiYht1HnF3Ij87nEBt+2JkOD8VlOdWhSN2PSsJmMftCaH6r28Ce", - "vzk7fXsWDaLf3pzj/1+c/XKGH96c/Xr66mz7Ow/+OuhXWH5hSiPeAns0WrHZWxdijFsGNiwNXHtC3Omp", - "t5RKgavGL2LRQ1unJBULnGtdid7au32XyGo6V0sqiUV5SBnNY9SnDChNszxwMpmz3kxfreiaKpJLkRSx", - "paJdxFuP5lefOoQwvLNfuAeSN87JpCvhd3258XbRw19s+kbY+aWmYyDfz7hxh5d8tBjf8nqfMKUpt0/X", - "pc739L4v9WbNe13qb3/TdYK5utaaj5TrFhTDsnobeVZWA09hRIuDyHTXkfYi18PNzgkoPd1mPgelzeLt", - "C5pVGrZZnweRkvG2ga1Txc5jtlVNP8GgtosQhF5f1eXSHneRH4GjVfr1z8S7z3XlurjaSrXnPDHHAiiv", - "TI+2K9LiKriXC6rjpbNsH4bxPtP2i36TdikoHj0Z72/gftFr2B6R87n3NBqQQoF9rF2yxRKUJnRFWWqu", - "3LaLl4oSkHzcIetUk+/Gg8fjwaOng4fj9+ElIminLElhO77mzvAlYW5kB7onGEXViuCUrYCsGFwbJaR8", - "0ziRgNs0qmGs2QrCkkYCmpGn8VKKjJm1f+yfHZuS564poXMNsrZ/r9ZqQYCrQgJhmtCE5vYZjcM1Matu", - "3P6RJhCWS6DJvEgHOFv5TdpDnr0vCi96XxJKsnn8aLzbu0L7efmwk3eLzd+fuv7YMjSF5xga+ltncZ1E", - "DbrHA9uWSiCa5rnVrzabFTccpOU7abbtRL2CNcG35dKFa7TXARue/xdnLTejq3U2EylOjhONyBmNl8RM", - "QdRSFGlCZkBorS1RRZ4Lqa0t5CYRWoh0wo8UAPn7w4e4l3VGEpgzjkhUxyPibGeKMB6nRQJkEr1Bi8ok", - "MrfmyyWba/vxuZap/XSauq9ePp1Eo4m1mFujKlPW5B/jAmmqhFllLLKZO7KUe2a24/1F+8s4/oWz/eUt", - "neGwewC0Ja0RukF5LYUR+Gc3EN+ZeZSa7WVogl9zI0e4KFTQm1Yumpb2P953XaPtSFQuCqMeqf2oiqqp", - "FKJpJw9vo3AWcAsPfNUjpivJJVuxFBbQI3aomhYKArfz9pBUWXIwrc1QvEjx9PAyvut8Z/ceuPwioPHk", - "EZKoJaRpCXJzFhQ8eEeLrwNj/SbkleHh6rJ6ROuX9WM3orO82UkYD21gu84FfNVPXh9DT6QOZx87DuNn", - "fMWk4HjxKE3fZq0KdHkUO9DXoFFRfsd8vZ/Fuh+B/YZpi86tbHgrqzStM12JsHIfXSbceB+sXNb7LoOj", - "4C0Dbpiehp9B3FaJaYKm3PAI1kg9nX33JGyj+u7JELjpnhDblMyK+dxyVo+RetfBRKH7B/vUj72fWeVB", - "th/6LtnCHLJIvZaHW9TbRJnC5g2hFr09e/Mq2jxu3VLmmv98/ssv0SA6//VtNIh+enex3UDm5t5AxG9Q", - "FT30NEE1lpKLt/8Yzmh8BUk/GGKRBkj2V7gmGmTGzM5jkRYZV9ueKweRFNfbxjJN9nz3xFEHdqEbIHaZ", - "0+tGVEuavp5Hz/7Y5uvYObo/Ddp2LZqmwlztplqvt5+Cp641oSRXUCRiWO7+6OLtP47bgtVq9ngQlRET", - "K7AnUs9xGUbaudG/DKW2EGcvNPVNmDtC57V8D5R2ZjLNDp+mKw7ed/B6gDw/rxmM6cwIJEqUGW0TP+Qh", - "L7fXlyWyzl+ERa37fRrqbsPChlQZvoeEsMppLnDIlnbcomBJWBBTWcWVdO3EaMe12KiTmeu2h6m4l9XK", - "YJB9IpucU5qN/7CnbL9UyotpHgf2d6Y0y6i5jDy/eEcKtKfnIGPgmi7qp6ANfdlyjJ7545OweQNWS2rP", - "VguubTrKIMog63tMq1YsQSHmSQaZ0RHt6st3tqgv7mbD+Y8/14+kKirHLj98FvUjNmEHRga+oJoaSXYt", - "mTWAtkjPvmMznheBt7mEarqTYpHUZxlttR6W477fuudb6YtmOc5nUJnhujs0LTTwPiKpnIywAXHNR9Gu", - "JhW3FQm0eijdR3e6PCM5XaeCGjLNJSgjoTD60GLQOSAISVI2h3gdp+6hVd0Wm+XDWkUsZhdBFRTC73S/", - "NJfUedE0rBD0Ht1JNJSC1A7OFJlgx0nUx7Jm/YFTwBrC7c/+JQtBEC8LflVfsPMHKb1MdmNi694NMux+", - "MWcco/92OTYqH27fq+/Q2Hr/7gloZKp0Rq/93vAk3PmQq1brOh242FC0Yn2dISFyGUsArpZCv4HFLmFU", - "u9npf7L2+dKlfuEujRsc0Hsst7+hxXafgXZ8xbVjPTDqaz5MYW64RXK41bvuHmMGn848FAYesNtQdogF", - "WpaI3hIL1SSMIMs2I6b2fdVLNZ3ebDaE/yQk+yA4xuPgXIRmouB6ROxzvrlo4PeKoBfegHBY0Mb3Bg9h", - "SWdXsMX9/v+YFcc7zJ+Iax6YvsjDk9/m5bqM2drdCLqNK6i2IYy1wLLmVPszxd5D7vyc3Im221NqsSQB", - "vsW/0D57V28KrtPWN1HXrmfZL1kKF+bWiTH76rD1L6Qo8rChAn9yrluS/Ni47e3rIxgIg/vuyZPj/aLe", - "xDUP2cXNWvEntIT79b7rWe8u/mTXS6HwLuVha5+/7EsLPkEmh0akbfDvq4dv7qeyXtBCQd3bV0i830Ns", - "eD8pba17GmvrL4cYtxmy1db9qhtONuOtTFmfPAgQo8I0M1scdiErXx19ngubb8FDhZymqQ2FVsQJb39r", - "U9ZcgS961lSCznjXrZQh+DcXZCYStPSYK0MwZvqQ7Bmf+kDzUv1GdXyn8ZdlcCzeLDFOPew0bmQaW8F2", - "E2ApCN14pOybrndwC+l1ckEI3DKKcy5pBmEnjjeV2u8bGeqf50aYrUBKloBy5OGJ5rjODo/G2+yJQeua", - "p9SAXaym21tmuKNYUly05/Vzfml5u/8Nq1pH/Q3H+/Jths5GgGT0Bn2c2Qc4569+6F8B8qByntmvftgR", - "I+3Qvoc7OmlcapHfltCEjMGMs51fzrMMEkY1pGtM64Qvx6LQZCFpDPMiJWpZaKMgjsjbJVMkQ1cjNL8w", - "jm/lUha5EUwrloBAYIVN5/sEMVsONgu6xwjmdmT/3peA28W/GhVZS3EFKhizFjThh+PqDnLu9K/O1Tq8", - "c2vNyZOSObsxR7rZyWjC62kE0K/pSBkFiCr0ukS33pPERy8fj8gleuNWXlET7txYiF7nZi50VKCcCC+J", - "avM1IEWO8Lv/HBu4ON/T49GE1+IoMTmLgdo6h8SA/VrIZGgYN7GGHucXUe6ccS3p0LSyE6oJpzwhnOpC", - "GqHINUj7c25UHmWj2+zabC4Ts5YNqJvwICeEs80YUkS4YroMa/haCvS9sYleesILxNQoiTFspsULkMN4", - "SSWNtWGudS4I44YTMAEX1fA3kjGl6ZVPPiaktBEZCLMZja9UTmOoiICMR+Q1T9fWLx9UCALkSLEUuE7X", - "DThNeNUMaePYgqoUnuPRwyDV+7eFXTPtvMsTquEulLqXVmHTghQ4psdQK5fZ6P6UsN8k01DmOTpMaG2m", - "vMYLgg/t8RMemu7INGPOLooxmdGz6GeQHFJyntEFKHJ6cR4NohVIm0wuGo8ejsZ4A8uB05xFz6LHo/Ho", - "sQtswY2ceAfPk3lKF17FjAM65iuQC0BnTWxpyRlumEIru+CgBh6lrUEDLqIrRokqcpArpoRMBlZgYNBp", - "wTVLEXJl6xeweitEqsgkSpnSwBlfTCIMJEkZB0P9YoYqhLmnz4X00Y+odTlfZmQMg0OrMCV4AdPx0s/y", - "EvdvUQFK/yCS9V75Lluqg4dm6wnRb8nCUAuSIVhdNN4fk2g4vGJCXVk/wuEwYcqI2OEiLybR++PDXf/s", - "gsJkVbUzssZ6/1ZZWB+NxwFLAa7f4jvBEORyaw7Z7ZjMT4PoiR0pxL/ljCftpK+fBtHTXfo1M6Zi+tAi", - "y6hcmyPb0mW5xJQWPF46JJjFuzVjt4p6c5GyuLp49XNFoUAOfdatahrAVAWSKSA41JpUGi/jTj7MaPnz", - "yFDVYMK3sgvZn1smfF92eQ4Ss0t4KJCMcrqwTrRXVvAwPpdUaVnEKLuRisnZjQZuRNAlaCMb1GDCcylu", - "1kNMP2Du425Eu49yfE+GeHV6/uLixIcLCX6MZ+ksFfEVJBOOL24ells5+8Kj8XDmDh8NIe1wF+SPyM/e", - "Odv9xGkGasKPnAuw0wyeC3HFQDk4TqJjhBeGdzvb1rIcwX47mvBLAOKD+5GSoVrJaCHEIoWSsE+szakM", - "YPDfW5C61AA2/a5i8Wmhl69XIH/SOj9Dd5/EwyC4YLwvmsbqXb6QNAFV9nKH6it681xwbrUndQHywtBJ", - "9Ozxo0F0IfIiV6dpKq4heSnkO5kqtK52ExdE7z/dlVzztPLNirY22WES3F4JV+SpoMkQPMuqIeXJ0Lc1", - "Yk+ogKLzDrthSkIhSWYkSDkE+cByQmW8ZCvD4XCjMRmpXkJGCm5upCdLkcGJFSEn1dQnk2I8fhwbVsBP", - "MJhwBZpII+Oy+gxWbjN+gKJRSs4J/4yKhoVXKRjVKU/eOBhvkklZkWqWU6lP5kJmw4RquknnqEDZH0FR", - "tbEqOOIRPetjzVZUN8Ihm8OHA8VfitTgFO33WpA8pTG4BA8eXfthvWWPOB3+TocfxsPvR9Ph+48PB4+e", - "Pg0/M3xg+XTO0sASf68I0qdMMviiZmW5dS6t2Kdc9RFm0/TRHxnlbA5K4xF9XH+enzFuOHGbVl8uz0Xc", - "h25ZGxW4GnYP0+IehlxESmqwpADJICDtLNeUzMHMUU2TLy33OiKoxGaNyI+oMgJJHdeFYLlFJw2dXeBk", - "5nW8sNQ784EtnIhWGq9OKnm06bkEs6cX5ySmaToip+5XPPnte6hRZ+rJ5l2eqKVIE0ekcBOnhTLEa9Sf", - "AVGCcEEEmufRG42UwkaRmHJrb0mBrgBzAG3LNl+mp/WAJ6wMBLWvtz7tLGajGU04GjBtCMu8SFGHiJeO", - "qxKwLrXmXhiXQWDoLWkjnM1sV7C2eYAduCbcm0tzujajuEzsRIqCJ0MtWU6M6sjjNc4GGPHFE7ZiSUFT", - "N0xI8gbqBtxCDdxkedhQoeBQZQSH7Elx8yV5r2SEDbUU6jTdYrNWCmLPbE3EVcmH7wlfgezGB6LJ5oP0", - "uZs9W39RDF2yrEitB7/lunp29rBRtIMja646MaK+H01vgCbPa6atELTuCl3NxOShAiplfnE3JZ5THb65", - "NXTNpq2VvHT97Fj5+sCJtsF+eDaNk/dE+mEL6KHkj1ZP5+6LSYtLLHw1Aus3a5D1DwM74KtM+R1GU+l+", - "dE8Y6iYT3xk5dzJ/LRdFiM+sZ9SKKTZjKdPr8rb81WD8J5a4qFhxXU+400RzM5l9WOvDYH/UWtAHzwtU", - "m3V3UD64Gc2N+jQ3Zlqp7QvXwEzP25l4F2zlk51axTQFqgB1q3oOuS1pYkMaT5n0+J5Is5vW/0C5YQb6", - "So5LXEqVysiiiSIeWhSzAG0JZlpW2+gVEj+CbqSdus/jMZzfKsy7GMRkd1pu4i6g+CNoz2q1KZwbpZ9p", - "F+WjWSUiDNwy/dU9kXm3/sSttEMHBbOzL0vqr3xWpwZ2/KlY+h5WkkbtgrFGZY4NctSlzqnmQZcElJk1", - "34XS8dHaySsP3Fr+jwkPZfUYkZcof83CJCyB23tzN33IgCiACTeLCacAIVRXZvQF06O5BEhAXWmRj4Rc", - "nNyY/+RSaHFy8/Ch/ZCnlPETO1gC89HSynPn3bQUXEhVd2IZprCCar/mRu1812IHCnTgVM6EZrEgkuCL", - "h8tJc0/s0KmociA3IEKRWr4mbcGe8XVbEtLlDoSvyiCJflH1ll5BFUxxXxpjJybkk8PRxhOHZXQBJ7mN", - "Yapm2m7d7Bws1QIIDvpFEeqLEFJSIch7xm1Bp6sSFBZiNtqFrFxESLo22tuJMLzto1TMd7qm49UkaVNb", - "bNj5GomVnBrYCDdxqes5ScUCg1E0i68UOeJCu1Aoa+KsURCZwZKumCFpuiYrKtd/I7pAK52r1OEZ2Pt/", - "zYRe1rZinxt99AvGyjjbpXvqHtS8rUv3JXzpaZg0j8oxUBWuJji2fh9oRbKOT5C6oEwnCv/p/dysAWM4", - "dAXYfiXDoXUgGxP7gmAVcvuG8M+QhLz0QSf3xH71wlEHSkdHXl+JDckuptIVLHqoNprxHtqcz9zbIxyd", - "8+g94aVbdeoWRg7rEPnVnFpYeRGNGv1YcAV0Gh4sAVcJlx3vvpSHQDbIz2zQaFZZChxf75wFw1ccasSa", - "3AbNT8bfb+/XrDR9h34BPdsxpGHl7IlzvJyqqgitrXkVSuYvchXy1sSDgmlFnr+4IJngTAs5qD2N2xcn", - "1GddB5v2gdiUeoo8GT+xFaPKBlV+ypAo1yJv1c69T9Nzc6aQ7lPuytaRRbQ/2Y7AZnXtlvjVIg/B2gy+", - "AB2KfbGwrF/BEcxlzumWi+2h0P8R9NcJfKrhTkBfGjK6kO9xA7TcVhXxXN0dtEOu1/ckpzd5eX9meb07", - "2p3p+XYi+pYE44RtH82ErxKaSq3CjHmqSWbOcnMj9URiVHZ0t7YamGYZ+HI9hqa+NzRFGySVopeGJy1b", - "UYimKUhbKNYWRXC+Os6N23d3zk1lKTmKjt+C90jjTsDnfWnZ/aGlwdvuwy8jhuSt6fGLqAwI3TARG31h", - "rk5sPdJpmSQU1coi9HrfrNl6X0/44cqwh3pDVVG0rpL716Pq2506NqzA7/FiFbYd8GKrqN43XrpFZg9+", - "IypRYrf42cX8XTwu4crrhXjaePNuixtQ9tK6Dn7d2ML0EX8CRCE+ShyJa54Kmhjumn5gGAu8Uf+m5Pfz", - "CxvsXPM2tXmTEV3KK+S1VA/12kct/Lv5XzD5O8vRO9ZXh8fcoDsXk/YusOau5jeFabRNv38VgOLAOvn6", - "pBdNGhjUPY+3JdF4v5dy6OB6KwO0gbrfYxkEjYRVB/C3SJcOWXURQqgnNLflHnpVOtmBYDWVow9KkyNN", - "Zc1VOvMPNRjrZ8Y63kjXE76BsMnvSidEzOcgFVFswbG8HYa0zqnSIMsJnUFhwhOof2U+UwmYF/kDy50B", - "ncZLBiusJQS6PQqyUdhLosZVBkbfClsNPnYz45fbxdfEEfmJLZYg7V9lgS2iMqv7+9AMMis00fQKSCr4", - "AuRowocWE0o/I/822LZDkIcD4gKKDWIhIUf/fjweD5+Ox+TVDyfq2HR0AdPNjo8HZEZTymOjSpmeJ4gB", - "cvTvh09rfS3iml3/OvD49F2ejof/q9Gps8yHA/y27PFoPHxS9ujBSI1apjhMVEdHlVfbf6oyHDpQRYPa", - "b3bJ+EGF8jXuKxUd995KLL51vP3/mWjUzW2X4tHIr6mPo3ZisSkaykp7u8qErcUMv4YTdj+dsKo22CUo", - "1PJqpQy/QbL5EXSjGKPPrd3BXkk2KVMa9XTVSzdVTcjDDpNvk1KqXQdIpbq+pdbA9A3SCkbOIeZtUE+X", - "NrCKYN/1zde9u0c3tbu4uqFbWGXu+AbxhDvASmcYi7iJmSXQpLx0B3n5DdDEXbl3Y2WczKuEZvyvhZtF", - "rEEPq4zOt9IlUPQHYyq+MWLBCI7yKmM6lsShwAr6aS2RZC93d/N53l9AQE/i0IMj3Wt5Mr/QG8pd2KxB", - "Bwot11B3gjlG1ZLlJYZtqGu/kxfmHPARsfgqYuM4hSQ2IjsFdyA4t1kJmXAywMaVjHoiwL16cGch36VG", - "0hOzfUjd1FoGI6fQ7lZJ1QvUfSOjXVT05uKom3PbIBTuLCoasVQGRH/roi4QKD13+lqdHbxpc2PCB4qG", - "F+Q3W07M5nZgWlW2zY4reagub4g5rHXzzlhjX9JP6jlUa1kryouzFrvxQT0RwS2yBGzihwMJ+3eWV2Rd", - "Q+CfhshpPflIi0Q79O6MK1sIfl/TaB9fTPh2xthuIm1YRCe8ZRLtTz3ibJx3xlzeqtL1k1xC2/RSHiFb", - "mWHw5ZjWfMqnFd1tTgJZFbtJwaoIeHBW3W3WVclyn7PfrQ0Ti2DaUENOwyG2GVb9jrdlKW3JC4+HexEX", - "pw6Gf3KR0SbXHrFx3U4OEnCCcam979P7pZU9fHfcHpjIELcdrAn3jrN/FRBKeV1x5bUDx9Yswt27Jm6T", - "3HW+rS9EbHYzdSO1S5rCFzVNDKF18tGD/FPTF7frAluRW8tIgYYHZ2lwdocSj5tsD9tNDYGSWB5R6Pr6", - "rSMKvW4RVtbtrWs8aiPJ+VH3mpJsSbOX6sw2+4y4apuFNNxou9qgPWjbe8AlXm1tbbaQ/9vlWa0yWHUX", - "dvE8WNGIJrjrj9Hfh5eXZ0OXymP41kWItFNrJoy6LM5zYobHUmMuPOioLcSOGy93/pWuI+oCj3KfvkUy", - "RUB3oOzSD1ixW1KsucxvdjLCBBm7GDxf1JQv2jF+fsZ379dVonNfs6a3XA2pp1v/7smTvmVijZeeZW0s", - "cmOZb5cT/5bm2AOtGWV6lm/9GEWzlDk5vT9k5aqVioU6qQAbfqITC1diskcOtwhCYcHDjZTrBY0j8SrX", - "ZLDkYXiauUhTcR32PGjU+auVW2mjWfB0XWXQZXNi106YIm5pGxiz/1TZZ57a3sOzVQ2mrlRm9MVOtF/E", - "YsejzBDWV316hU4Gs2hMOGymtgySp3R9jSXyTlxKuR1SHcoZ05LKNbkoe7tyw9xwnwS1rFWwQtTcaEIX", - "lHFlb+IzKa4VSOLq+k644CQVMU2XQuln3z969MiFJphRl1QRGvua4g9yuoAHA/LAjfvAJqJ84IZ8UFag", - "8BHTsqxnq/2I1eIwbaUuJLdFLeoZD0OGEweCat/P7elwHze7zlxfKOomsA6sKhzKI1MB92tMTVhtAUOA", - "L3HlliICxOkYxMok5I7+i36t3v695droVvT/vHTQWEEfBVSZRaVr81WkpIxFlhkpodY8XkrBRaF8BkqP", - "YCyhvxXDWLb/flGMU3xZHLsl9CEZf/7CiQi6uKUbkPvRfcC7+RVrZvMIIvpnhmkhtt/Lq5E3qoSlJl8U", - "LLnNZeEghJrdfJVZA1///E36FxhRwhbmpqmFr/C+geIkKPYBttLcG9vsT0N1dj//TXd356CEhRIpuXj7", - "j+HMpjXfTnxKU130myK9yLetPjft3fM5ZjcVOsLcL9+kl7JDAFF+e/2oT9gOOg22+tNIHdzOF9af7BL6", - "9Kcf1phG35rfvlmLW3XyEUtnG+lQFHqbIa4Cnij0RovcF5JHt7AslXsz3Xa0MXnoikLnhUYrR8rmEK/j", - "FP77AeX+HlBqVC0K3TKYlRWTT6pH2LB0tZHDZbXhew3U7tQ07s/z2Fcb+4uFaH+hxBZlYHcuYcXwzujr", - "I9fLLXew7oLLeqWYjz6rI37j61n5aFVWZ668J0bkt1p5+kZmxcLnzfVl7n33vocsFHrhZ6xt9Z23i0YE", - "2EmWP7l1OEGtWrt9emwIuPLX4UvGmVpCMjwNFV1lGShNs9wIOSyd26y6PnedR+THgkrKNVh/uRmQNy+f", - "P378+PvR5heQxlIurT/KQStxviyHLsQs5dH40SbGZkaSsTQljBvRtpCg1IDkmFueaLm2tk8spSOb4H4D", - "Wq6Hp3PzQzdVUbFY2FhRTHGP1dhqteOrSmhybZmg2sSm0vHf4rlRBpzatJgKedEWZt5BoqTMnh698YNv", - "HGOr26ZUK+MBNh0ofjYb6dlxsu/wqy8iJ8tV3lmAHU3T+rBNsHWqEQZc7+778G1Osj3rVB+LfuvpoXxG", - "5UquuerlgtdlXQ6SnL/AcmSYZ3jBlMaKaWXyslEXyyLfhGSR3z+Oa3Mcrl41skB+qeS9PntkCV/cyP8L", - "AAD///9uXeRvzwAA", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index dec71c42..112334b9 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -1274,8 +1274,163 @@ paths: $ref: "#/components/schemas/CaptureSession" "404": $ref: "#/components/responses/NotFoundError" + /events/publish: + post: + summary: Publish an event into the event bus + description: > + Injects an event into the global event bus. + The event is assigned a monotonically increasing sequence number and + fanned out to all open stream connections. + operationId: publishEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishEventRequest" + responses: + "200": + description: Event accepted and published + content: + application/json: + schema: + $ref: "#/components/schemas/PublishedEnvelope" + "400": + $ref: "#/components/responses/BadRequestError" + /events/stream: + get: + summary: Stream events as Server-Sent Events + description: > + Opens a live SSE stream of events from the global event bus. + Each frame has the form `id: {seq}\ndata: {envelope-json}\n\n`. + Sequence numbers are process-monotonic and do not reset between sessions; + a Last-Event-ID from any previous session is valid for resuming the stream. + Drops are signaled by gaps in the `id` field: if the received `id` is more + than 1 ahead of the last-seen `id`, events were evicted from the buffer. + Clients should track the last-seen `id` to detect gaps. + Fresh connections with no Last-Event-ID start from the current sequence + and only see new events. + operationId: streamEvents + parameters: + - in: header + name: Last-Event-ID + schema: + type: string + required: false + description: > + Resume after this sequence number. Omit or send 0 to start from + the current position. Sequence numbers are process-monotonic, so + a value from a previous session resumes correctly from that point. + responses: + "200": + description: Live SSE stream of events. + headers: + X-SSE-Content-Type: + description: Media type of SSE data events (application/json) + schema: + type: string + const: application/json + content: + text/event-stream: + schema: + $ref: "#/components/schemas/PublishedEnvelope" components: schemas: + Event: + type: object + description: A capture event. + required: + - type + properties: + ts: + type: integer + format: int64 + description: Unix timestamp in microseconds. Defaults to the current time when omitted. + type: + type: string + description: Event type identifier. + category: + type: string + description: Event category. + enum: + - console + - network + - page + - interaction + - liveview + - captcha + - system + source: + $ref: "#/components/schemas/EventSource" + data: + description: Arbitrary event payload. + truncated: + type: boolean + description: Set by the server when the data field was truncated to fit the size limit. + EventSource: + type: object + description: Provenance of the event. + properties: + kind: + type: string + description: > + Source kind. "kernel_api" is reserved for server-generated events. + enum: + - cdp + - kernel_api + - extension + - local_process + event: + type: string + metadata: + type: object + additionalProperties: + type: string + additionalProperties: false + PublishEventRequest: + type: object + description: Request body for publishing an event into the event bus. + required: + - type + properties: + type: + type: string + description: Event type identifier. + category: + type: string + description: Event category. + enum: + - console + - network + - page + - interaction + - liveview + - captcha + - system + default: system + source: + $ref: "#/components/schemas/EventSource" + data: + description: Capture Session Event Payload + additionalProperties: false + PublishedEnvelope: + type: object + description: The envelope assigned to a successfully published event. + required: + - seq + - event + properties: + seq: + type: integer + format: int64 + minimum: 1 + description: > + Process-monotonic sequence number assigned across the lifetime of + the server. Use with Last-Event-ID to resume the SSE stream from + this point. + event: + $ref: "#/components/schemas/Event" + additionalProperties: false CaptureConfig: type: object description: Capture filtering preferences. @@ -1318,7 +1473,9 @@ components: seq: type: integer format: int64 - description: Monotonically increasing sequence number (last published). + description: >- + Process-monotonic sequence number of the last published event. + Does not reset between sessions. minimum: 0 created_at: type: string diff --git a/server/scripts/oapi/patch_sse_methods/main.go b/server/scripts/oapi/patch_sse_methods/main.go index 50db1447..e55e1a45 100644 --- a/server/scripts/oapi/patch_sse_methods/main.go +++ b/server/scripts/oapi/patch_sse_methods/main.go @@ -20,6 +20,8 @@ const oldBlock = ` const newBlock = ` w.Header().Set("X-SSE-Content-Type", fmt.Sprint(response.Headers.XSSEContentType)) + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") w.WriteHeader(200) if closer, ok := response.Body.(io.ReadCloser); ok {