p2p-swift / Sources / IrdP2PSignaling / Mailbox / MailboxClient.swift
650 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
import Foundation

/// The mailbox HTTP API (`/mailbox/v1`): deposit opaque bytes for a room and
/// replay them later. Payloads are whatever you give it — encrypt first; the
/// node stores exactly the bytes you send.
public actor MailboxClient {
    public static let maxLimit = 500

    public let endpoint: URL
    private let tokens: any MailboxTokenProvider
    private let http: any HTTPTransport
    private let origin: String?
    private let timeout: TimeInterval

    public init(endpoint: URL, tokens: any MailboxTokenProvider, http: any HTTPTransport, origin: String? = nil, timeout: TimeInterval = 20) {
        self.endpoint = endpoint
        self.tokens = tokens
        self.http = http
        self.origin = origin
        self.timeout = timeout
    }

    /// `POST /rooms/<room>/messages` with the raw payload. `deduped` is true when
    /// the node already held this exact payload for the room.
    public func deposit(room: String, _ payload: Data) async throws -> DepositResult {
        try MailboxWire.validateRoom(room)
        guard !payload.isEmpty else { throw P2PError.config("mailbox payload is empty") }
        let response = try await authorized(room: room) { token in
            var r = URLRequest.p2p(MailboxWire.messagesURL(endpoint: self.endpoint, room: room), method: "POST", origin: self.origin, timeout: self.timeout)
            r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
            r.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
            r.httpBody = payload
            return r
        }
        let d: DepositResponse
        do { d = try JSONDecoder().decode(DepositResponse.self, from: response.body) } catch { throw P2PError.transport("deposit response not decodable: \(error)") }
        return DepositResult(seq: d.seq, id: d.id, expiresAt: Date(timeIntervalSince1970: d.expires_at), deduped: d.deduped ?? (response.status == 200))
    }

    /// `GET /rooms/<room>/messages?after=|before=&limit=`. `before` wins when both are given.
    public func replay(room: String, after: MailboxSeq? = nil, before: MailboxSeq? = nil, limit: Int = 100) async throws -> ReplayPage {
        try MailboxWire.validateRoom(room)
        guard limit > 0 else { throw P2PError.config("limit must be positive") }
        var comps = URLComponents(url: MailboxWire.messagesURL(endpoint: endpoint, room: room), resolvingAgainstBaseURL: false)!
        var items: [URLQueryItem] = []
        if let before { items.append(URLQueryItem(name: "before", value: before.raw)) } else if let after { items.append(URLQueryItem(name: "after", value: after.raw)) }
        items.append(URLQueryItem(name: "limit", value: String(min(limit, MailboxClient.maxLimit))))
        comps.queryItems = items
        let url = comps.url!
        let response = try await authorized(room: room) { token in
            var r = URLRequest.p2p(url, origin: self.origin, timeout: self.timeout)
            r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
            return r
        }
        let rr: ReplayResponse
        do { rr = try JSONDecoder().decode(ReplayResponse.self, from: response.body) } catch { throw P2PError.transport("replay response not decodable: \(error)") }
        let messages = try rr.messages.map { item -> MailboxMessage in
            guard let payload = Data(base64Encoded: item.payload) else { throw P2PError.transport("replay payload is not base64") }
            return MailboxMessage(seq: item.seq, id: item.id, timestampMs: item.ts, payload: payload)
        }
        return ReplayPage(messages: messages, hasMore: rr.has_more ?? false, oldestSeq: rr.oldest_seq, latestSeq: rr.latest_seq)
    }

    /// Pages forward from `after` until the end. `gap` is true when the cursor is
    /// older than what the node still holds — history was evicted in between.
    public func replayAll(room: String, after: MailboxSeq?, limit: Int = MailboxClient.maxLimit) async throws -> (messages: [MailboxMessage], cursor: MailboxSeq?, gap: Bool) {
        var cursor = after
        var all: [MailboxMessage] = []
        var gap = false
        var first = true
        while true {
            let page = try await replay(room: room, after: cursor, limit: limit)
            if first { gap = MailboxClient.isHistoryGap(cursor: after, page: page); first = false }
            all.append(contentsOf: page.messages)
            if let last = page.messages.last { cursor = last.seq }
            if !page.hasMore || page.messages.isEmpty { break }
        }
        return (all, cursor, gap)
    }

    /// True when `cursor` is older than the page's `oldest_seq`: messages between
    /// them were evicted, so the caller should show "history truncated".
    public static func isHistoryGap(cursor: MailboxSeq?, page: ReplayPage) -> Bool {
        guard let cursor, let oldest = page.oldestSeq else { return false }
        return cursor < oldest
    }

    /// Runs a request with a bearer token; on 401/403 invalidates it and retries exactly once.
    private func authorized(room: String, build: @Sendable (String) -> URLRequest) async throws -> HTTPResponse {
        var token = try await tokens.token(for: room)
        var response = try await http.send(build(token.token))
        if response.status == 401 || response.status == 403 {
            await tokens.invalidate(for: room)
            token = try await tokens.token(for: room)
            response = try await http.send(build(token.token))
        }
        guard response.isSuccess else { throw MailboxWire.error(response) }
        return response
    }
}

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/p2p-swift.git