diff --git a/cmd/knotserver/main.go b/cmd/knotserver/main.go index 0418308d..ec10d351 100644 --- a/cmd/knotserver/main.go +++ b/cmd/knotserver/main.go @@ -29,7 +29,7 @@ func main() { log.Fatalf("failed to setup db: %s", err) } - mux, err := knotserver.Setup(c, db) + mux, err := knotserver.Setup(ctx, c, db) if err != nil { log.Fatal(err) } diff --git a/go.mod b/go.mod index 40a56134..cd8e09d3 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-git/go-git/v5 v5.12.0 github.com/google/uuid v1.6.0 github.com/gorilla/sessions v1.4.0 + github.com/gorilla/websocket v1.5.1 github.com/ipfs/go-cid v0.4.1 github.com/mattn/go-sqlite3 v1.14.24 github.com/microcosm-cc/bluemonday v1.0.27 diff --git a/go.sum b/go.sum index b9e6206a..4c4a91b3 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= diff --git a/knotserver/handler.go b/knotserver/handler.go index f10c3dc3..ccbca374 100644 --- a/knotserver/handler.go +++ b/knotserver/handler.go @@ -1,15 +1,25 @@ package knotserver import ( + "context" + "encoding/json" "fmt" + "log" "net/http" "github.com/go-chi/chi/v5" "github.com/icyphox/bild/knotserver/config" "github.com/icyphox/bild/knotserver/db" + "github.com/icyphox/bild/knotserver/jsclient" ) -func Setup(c *config.Config, db *db.DB) (http.Handler, error) { +type Handle struct { + c *config.Config + db *db.DB + js *jsclient.JetstreamClient +} + +func Setup(ctx context.Context, c *config.Config, db *db.DB) (http.Handler, error) { r := chi.NewRouter() h := Handle{ @@ -17,6 +27,11 @@ func Setup(c *config.Config, db *db.DB) (http.Handler, error) { db: db, } + err := h.StartJetstream(ctx) + if err != nil { + return nil, fmt.Errorf("failed to start jetstream: %w", err) + } + r.Get("/", h.Index) r.Route("/{did}", func(r chi.Router) { // Repo routes @@ -55,9 +70,31 @@ func Setup(c *config.Config, db *db.DB) (http.Handler, error) { return r, nil } -type Handle struct { - c *config.Config - db *db.DB +func (h *Handle) StartJetstream(ctx context.Context) error { + colections := []string{"sh.bild.publicKeys"} + dids := []string{} + + h.js = jsclient.NewJetstreamClient(colections, dids) + messages, err := h.js.ReadJetstream(ctx) + if err != nil { + return fmt.Errorf("failed to read from jetstream: %w", err) + } + + go func() { + for msg := range messages { + var data map[string]interface{} + if err := json.Unmarshal(msg, &data); err != nil { + log.Printf("error unmarshaling message: %v", err) + continue + } + + if kind, ok := data["kind"].(string); ok && kind == "commit" { + log.Printf("commit event: %+v", data) + } + } + }() + + return nil } func (h *Handle) Multiplex(w http.ResponseWriter, r *http.Request) { diff --git a/knotserver/jsclient/jetstream.go b/knotserver/jsclient/jetstream.go new file mode 100644 index 00000000..8665cc0b --- /dev/null +++ b/knotserver/jsclient/jetstream.go @@ -0,0 +1,161 @@ +package jsclient + +import ( + "context" + "fmt" + "log" + "net/url" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type JetstreamClient struct { + collections []string + dids []string + conn *websocket.Conn + mu sync.RWMutex + reconnectCh chan struct{} +} + +func NewJetstreamClient(collections, dids []string) *JetstreamClient { + return &JetstreamClient{ + collections: collections, + dids: dids, + reconnectCh: make(chan struct{}, 1), + } +} + +func (j *JetstreamClient) buildWebsocketURL(queryParams string) url.URL { + + u := url.URL{ + Scheme: "wss", + Host: "jetstream1.us-west.bsky.network", + Path: "/subscribe", + RawQuery: queryParams, + } + + fmt.Println("URL:", u.String()) + return u +} + +// UpdateCollections updates the collections list and triggers a reconnection +func (j *JetstreamClient) UpdateCollections(collections []string) { + j.mu.Lock() + j.collections = collections + j.mu.Unlock() + j.triggerReconnect() +} + +// UpdateDids updates the DIDs list and triggers a reconnection +func (j *JetstreamClient) UpdateDids(dids []string) { + j.mu.Lock() + j.dids = dids + j.mu.Unlock() + j.triggerReconnect() +} + +func (j *JetstreamClient) triggerReconnect() { + select { + case j.reconnectCh <- struct{}{}: + default: + // Channel already has a pending reconnect + } +} + +func (j *JetstreamClient) buildQueryParams(cursor int64) string { + j.mu.RLock() + defer j.mu.RUnlock() + + var collections, dids string + if len(j.collections) > 0 { + collections = fmt.Sprintf("wantedCollections=%s", j.collections[0]) + for _, collection := range j.collections[1:] { + collections += fmt.Sprintf("&wantedCollections=%s", collection) + } + } + if len(j.dids) > 0 { + for i, did := range j.dids { + if i == 0 { + dids = fmt.Sprintf("wantedDids=%s", did) + } else { + dids += fmt.Sprintf("&wantedDids=%s", did) + } + } + } + + var queryStr string + if collections != "" && dids != "" { + queryStr = collections + "&" + dids + } else if collections != "" { + queryStr = collections + } else if dids != "" { + queryStr = dids + } + + return queryStr +} + +func (j *JetstreamClient) connect(cursor int64) error { + queryParams := j.buildQueryParams(cursor) + u := j.buildWebsocketURL(queryParams) + + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + + conn, _, err := dialer.Dial(u.String(), nil) + if err != nil { + return err + } + + if j.conn != nil { + j.conn.Close() + } + j.conn = conn + return nil +} + +func (j *JetstreamClient) readMessages(ctx context.Context, messages chan []byte) { + defer close(messages) + defer j.conn.Close() + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-j.reconnectCh: + // Reconnect with new parameters + // cursor := time.Now().Add(-5 * time.Second).UnixMicro() + if err := j.connect(0); err != nil { + log.Printf("error reconnecting to jetstream: %v", err) + return + } + case <-ticker.C: + _, message, err := j.conn.ReadMessage() + if err != nil { + log.Printf("error reading from websocket: %v", err) + return + } + messages <- message + } + } +} + +func (j *JetstreamClient) ReadJetstream(ctx context.Context) (chan []byte, error) { + fiveSecondsAgo := time.Now().Add(-5 * time.Second).UnixMicro() + + if err := j.connect(fiveSecondsAgo); err != nil { + log.Printf("error connecting to jetstream: %v", err) + return nil, err + } + + messages := make(chan []byte) + go j.readMessages(ctx, messages) + + return messages, nil +}