~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
574 lines · 12.9 KB Raw History
package pdsclient

import (
	"cmp"
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"math"
	"net"
	"net/http"
	"net/url"
	"reflect"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/fxamacker/cbor/v2"
	"github.com/ipfs/go-cid"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/wsconn"
)

const (
	initialReconnectDelay = 500 * time.Millisecond
	maxReconnectDelay     = 15 * time.Second
	maxReconnectAttempts  = 12
	localHandshakeTimeout = 10 * time.Second
)

var repoFrameDecMode = func() cbor.DecMode {
	mode, err := cbor.DecOptions{DefaultMapType: reflect.TypeFor[map[string]any]()}.DecMode()
	if err != nil {
		panic(err)
	}
	return mode
}()

func newLocalSubscribe(ctx context.Context, handlers Handlers, base *url.URL, cursor *int64) (RepoEventStream, error) {
	if handlers == nil {
		return nil, fmt.Errorf("%w: local handler registry is required", ErrInvalidConfig)
	}
	return openWebSocketStream(ctx, subscribeURL(base, cursor), wsconn.Config{
		HandshakeTimeout: localHandshakeTimeout,
		Dial: func(ctx context.Context, _, address string) (net.Conn, error) {
			return handlers.DialHTTP(ctx, address)
		},
	})
}

func newRemoteSubscribe(ctx context.Context, base *url.URL, tlsConfig *tls.Config, cursor *int64) (RepoEventStream, error) {
	return openWebSocketStream(ctx, subscribeURL(base, cursor), wsconn.Config{TLSConfig: tlsConfig})
}

func subscribeURL(base *url.URL, cursor *int64) string {
	out := *base
	switch strings.ToLower(out.Scheme) {
	case "https":
		out.Scheme = "wss"
	default:
		out.Scheme = "ws"
	}
	out.Path = strings.TrimRight(out.Path, "/") + "/xrpc/com.atproto.sync.subscribeRepos"
	out.RawPath = ""
	out.Fragment = ""
	out.RawQuery = ""
	if cursor != nil {
		query := url.Values{}
		query.Set("cursor", strconv.FormatInt(*cursor, 10))
		out.RawQuery = query.Encode()
	}
	return out.String()
}

func openWebSocketStream(ctx context.Context, rawURL string, cfg wsconn.Config) (RepoEventStream, error) {
	conn, err := wsconn.DialConfig(ctx, rawURL, cfg)
	if err != nil {
		var handshake *wsconn.HandshakeError
		if errors.As(err, &handshake) {
			message := cmp.Or(http.StatusText(handshake.StatusCode), "websocket handshake failed")
			return nil, &XRPCError{StatusCode: handshake.StatusCode, Code: "WebSocketHandshake", Message: message}
		}
		return nil, fmt.Errorf("pdsclient: %w", err)
	}
	return &wsEventStream{conn: conn}, nil
}

type wsEventStream struct {
	conn   *wsconn.Conn
	cursor atomic.Int64
}

func (s *wsEventStream) Next(ctx context.Context) (RepoEvent, error) {
	for {
		data, err := s.conn.Read(ctx)
		if err != nil {
			return RepoEvent{}, err
		}
		event, ok, err := decodeRepoFrame(data)
		if err != nil {
			return RepoEvent{}, err
		}
		if !ok {
			continue
		}
		if event.Seq >= 0 {
			s.cursor.Store(event.Seq + 1)
		}
		return event, nil
	}
}

func (s *wsEventStream) Cursor() int64 {
	value := s.cursor.Load()
	if value == 0 {
		return -1
	}
	return value - 1
}

func (s *wsEventStream) Close() error {
	return s.conn.Close()
}

type repoEventStream struct {
	ctx    context.Context
	cancel context.CancelFunc
	open   func(context.Context, *int64) (RepoEventStream, error)

	mu       sync.Mutex
	stream   RepoEventStream
	cursor   int64
	closed   bool
	delay    time.Duration
	failures int
}

func newRepoEventStream(parent context.Context, cursor *int64, open func(context.Context, *int64) (RepoEventStream, error)) (RepoEventStream, error) {
	if open == nil {
		return nil, fmt.Errorf("%w: subscription dial is not configured", ErrInvalidConfig)
	}
	ctx, cancel := context.WithCancel(parent)
	initial := int64(-1)
	if cursor != nil {
		initial = *cursor
	}
	first, err := open(ctx, cursor)
	if err != nil {
		cancel()
		return nil, err
	}
	return &repoEventStream{
		ctx:    ctx,
		cancel: cancel,
		open:   open,
		stream: first,
		cursor: initial,
		delay:  initialReconnectDelay,
	}, nil
}

func (s *repoEventStream) Next(ctx context.Context) (RepoEvent, error) {
	for {
		s.mu.Lock()
		if s.closed {
			s.mu.Unlock()
			return RepoEvent{}, ErrClosed
		}
		stream := s.stream
		s.mu.Unlock()

		if stream == nil {
			if err := s.ctx.Err(); err != nil {
				return RepoEvent{}, err
			}
			s.mu.Lock()
			var cursor *int64
			if s.cursor >= 0 {
				value := s.cursor
				cursor = &value
			}
			s.mu.Unlock()
			dialCtx, cancel := context.WithCancel(s.ctx)
			stop := context.AfterFunc(ctx, cancel)
			stream, err := s.open(dialCtx, cursor)
			stop()
			cancel()
			if err != nil {
				if ctxErr := ctx.Err(); ctxErr != nil {
					return RepoEvent{}, ctxErr
				}
				if s.ctx.Err() != nil {
					return RepoEvent{}, s.ctx.Err()
				}
				if permanentStreamError(err) {
					return RepoEvent{}, err
				}
				if s.restored(err) {
					continue
				}
				if s.failures+1 > maxReconnectAttempts {
					return RepoEvent{}, err
				}
				s.failures++
				if waitErr := waitContext(ctx, s.delay); waitErr != nil {
					return RepoEvent{}, waitErr
				}
				s.delay = nextReconnectDelay(s.delay)
				continue
			}
			s.mu.Lock()
			if s.closed {
				s.mu.Unlock()
				_ = stream.Close()
				return RepoEvent{}, ErrClosed
			}
			s.stream = stream
			s.mu.Unlock()
			continue
		}

		event, err := stream.Next(ctx)
		if err == nil {
			s.mu.Lock()
			if event.Seq >= 0 {
				s.cursor = event.Seq
			}
			s.failures = 0
			s.delay = initialReconnectDelay
			s.mu.Unlock()
			return event, nil
		}
		if ctxErr := ctx.Err(); ctxErr != nil {
			return RepoEvent{}, ctxErr
		}
		_ = stream.Close()
		s.mu.Lock()
		if s.stream == stream {
			s.stream = nil
		}
		s.mu.Unlock()
		if s.ctx.Err() != nil {
			return RepoEvent{}, s.ctx.Err()
		}
		if permanentStreamError(err) {
			return RepoEvent{}, err
		}
		if s.restored(err) {
			continue
		}
		if s.failures+1 > maxReconnectAttempts {
			return RepoEvent{}, err
		}
		s.failures++
		if waitErr := waitContext(ctx, s.delay); waitErr != nil {
			return RepoEvent{}, waitErr
		}
		s.delay = nextReconnectDelay(s.delay)
	}
}

func (s *repoEventStream) restored(err error) bool {
	if !wsconn.Restored(err) {
		return false
	}
	s.mu.Lock()
	s.failures = 0
	s.delay = initialReconnectDelay
	s.mu.Unlock()
	return true
}

func (s *repoEventStream) Cursor() int64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.cursor
}

func (s *repoEventStream) Close() error {
	s.mu.Lock()
	if s.closed {
		s.mu.Unlock()
		return nil
	}
	s.closed = true
	stream := s.stream
	s.stream = nil
	s.mu.Unlock()
	s.cancel()
	if stream != nil {
		return stream.Close()
	}
	return nil
}

func nextReconnectDelay(current time.Duration) time.Duration {
	next := current * 2
	if next > maxReconnectDelay {
		return maxReconnectDelay
	}
	return next
}

func waitContext(ctx context.Context, delay time.Duration) error {
	timer := time.NewTimer(delay)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}

func permanentStreamError(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, ErrProtocol) || errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrClosed) {
		return true
	}
	var xrpcErr *XRPCError
	if errors.As(err, &xrpcErr) {
		if xrpcErr.StatusCode == 0 {
			return true
		}
		if xrpcErr.StatusCode >= 400 && xrpcErr.StatusCode < 500 {
			return xrpcErr.StatusCode != http.StatusRequestTimeout && xrpcErr.StatusCode != http.StatusTooManyRequests
		}
	}
	return false
}

type frameHeader struct {
	Op int64  `cbor:"op"`
	T  string `cbor:"t"`
}

func DecodeRepoFrame(data []byte) (RepoEvent, bool, error) {
	return decodeRepoFrame(data)
}

func decodeRepoFrame(data []byte) (RepoEvent, bool, error) {
	var header frameHeader
	rest, err := repoFrameDecMode.UnmarshalFirst(data, &header)
	if err != nil {
		return RepoEvent{}, false, fmt.Errorf("%w: decoding frame header: %v", ErrProtocol, err)
	}
	if header.Op < 0 {
		var body struct {
			Error   string `cbor:"error"`
			Message string `cbor:"message"`
		}
		_ = cbor.Unmarshal(rest, &body)
		code := strings.TrimSpace(body.Error)
		message := strings.TrimSpace(body.Message)
		code = cmp.Or(code, "StreamError")
		message = cmp.Or(message, "stream error")
		return RepoEvent{}, false, &XRPCError{Code: code, Message: message}
	}
	if header.T != "" && header.T != "#commit" {
		return RepoEvent{}, false, nil
	}
	var body map[string]any
	if err := repoFrameDecMode.Unmarshal(rest, &body); err != nil {
		return RepoEvent{}, false, fmt.Errorf("%w: decoding commit frame: %v", ErrProtocol, err)
	}
	event, err := commitFromMap(body)
	if err != nil {
		return RepoEvent{}, false, err
	}
	return event, true, nil
}

func commitFromMap(body map[string]any) (RepoEvent, error) {
	var event RepoEvent
	repoRaw, err := requiredString(body["repo"])
	if err != nil {
		return RepoEvent{}, err
	}
	repo, err := syntax.ParseDID(repoRaw)
	if err != nil {
		return RepoEvent{}, fmt.Errorf("%w: invalid repo did", ErrProtocol)
	}
	event.Repo = repo
	seq, ok := int64Value(body["seq"])
	if !ok || seq < 0 {
		return RepoEvent{}, fmt.Errorf("%w: invalid seq", ErrProtocol)
	}
	event.Seq = seq
	commit, err := cidValue(body["commit"])
	if err != nil {
		return RepoEvent{}, err
	}
	if commit == nil {
		return RepoEvent{}, fmt.Errorf("%w: missing commit cid", ErrProtocol)
	}
	event.Commit = *commit
	revRaw, err := requiredString(body["rev"])
	if err != nil {
		return RepoEvent{}, err
	}
	rev, err := syntax.ParseTID(revRaw)
	if err != nil {
		return RepoEvent{}, fmt.Errorf("%w: invalid rev", ErrProtocol)
	}
	event.Rev = rev
	since, err := optionalString(body["since"])
	if err != nil {
		return RepoEvent{}, err
	}
	if since != nil {
		parsed, err := syntax.ParseTID(*since)
		if err != nil {
			return RepoEvent{}, fmt.Errorf("%w: invalid since", ErrProtocol)
		}
		event.Since = &parsed
	}
	prevData, err := cidValue(body["prevData"])
	if err != nil {
		return RepoEvent{}, err
	}
	event.PrevData = prevData
	if blocks, ok := body["blocks"].([]byte); ok {
		event.Blocks = append([]byte(nil), blocks...)
	}
	ops, err := repoOpsValue(body["ops"])
	if err != nil {
		return RepoEvent{}, err
	}
	event.Ops = ops
	blobs, err := cidListValue(body["blobs"])
	if err != nil {
		return RepoEvent{}, err
	}
	event.Blobs = blobs
	if rawTime, ok := body["time"].(string); ok && rawTime != "" {
		parsed, err := time.Parse(time.RFC3339Nano, rawTime)
		if err != nil {
			return RepoEvent{}, fmt.Errorf("%w: invalid event time", ErrProtocol)
		}
		event.Time = parsed
	}
	if tooBig, ok := body["tooBig"].(bool); ok {
		event.TooBig = tooBig
	}
	return event, nil
}

func requiredString(value any) (string, error) {
	raw, ok := value.(string)
	if !ok || strings.TrimSpace(raw) == "" {
		return "", fmt.Errorf("%w: missing string field", ErrProtocol)
	}
	return raw, nil
}

func optionalString(value any) (*string, error) {
	if value == nil {
		return nil, nil
	}
	raw, ok := value.(string)
	if !ok {
		return nil, fmt.Errorf("%w: invalid string field", ErrProtocol)
	}
	if raw == "" {
		return nil, nil
	}
	return &raw, nil
}

func int64Value(value any) (int64, bool) {
	switch number := value.(type) {
	case int64:
		return number, true
	case uint64:
		if number <= math.MaxInt64 {
			return int64(number), true
		}
	case int:
		return int64(number), true
	case uint:
		if uint64(number) <= math.MaxInt64 {
			return int64(number), true
		}
	case uint32:
		return int64(number), true
	case int32:
		return int64(number), true
	}
	return 0, false
}

func cidValue(value any) (*cid.Cid, error) {
	if value == nil {
		return nil, nil
	}
	tag, ok := value.(cbor.Tag)
	if !ok {
		return nil, fmt.Errorf("%w: invalid cid field", ErrProtocol)
	}
	if tag.Number != 42 {
		return nil, fmt.Errorf("%w: unexpected cid tag", ErrProtocol)
	}
	raw, ok := tag.Content.([]byte)
	if !ok || len(raw) < 2 || raw[0] != 0 {
		return nil, fmt.Errorf("%w: invalid cid bytes", ErrProtocol)
	}
	parsed, err := cid.Cast(raw[1:])
	if err != nil || !parsed.Defined() {
		return nil, fmt.Errorf("%w: invalid cid", ErrProtocol)
	}
	return &parsed, nil
}

func repoOpsValue(value any) ([]RepoOp, error) {
	if value == nil {
		return nil, nil
	}
	list, ok := value.([]any)
	if !ok {
		return nil, fmt.Errorf("%w: invalid ops", ErrProtocol)
	}
	out := make([]RepoOp, 0, len(list))
	for _, item := range list {
		fields, ok := item.(map[string]any)
		if !ok {
			return nil, fmt.Errorf("%w: invalid repo op", ErrProtocol)
		}
		action, err := requiredString(fields["action"])
		if err != nil {
			return nil, err
		}
		path, err := requiredString(fields["path"])
		if err != nil {
			return nil, err
		}
		opCID, err := cidValue(fields["cid"])
		if err != nil {
			return nil, err
		}
		prev, err := cidValue(fields["prev"])
		if err != nil {
			return nil, err
		}
		out = append(out, RepoOp{Action: action, Path: path, CID: opCID, Prev: prev})
	}
	return out, nil
}

func cidListValue(value any) ([]cid.Cid, error) {
	if value == nil {
		return nil, nil
	}
	list, ok := value.([]any)
	if !ok {
		return nil, fmt.Errorf("%w: invalid blobs", ErrProtocol)
	}
	out := make([]cid.Cid, 0, len(list))
	for _, item := range list {
		parsed, err := cidValue(item)
		if err != nil {
			return nil, err
		}
		if parsed == nil {
			return nil, fmt.Errorf("%w: invalid blob cid", ErrProtocol)
		}
		out = append(out, *parsed)
	}
	return out, nil
}