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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151 | import Foundation
import IrdP2PCrypto
import IrdP2PSignaling
/// Everything needed to talk to one p2p app: its hostname, the salt that
/// namespaces its rooms, the `Origin` its allowlist admits, and the injectable
/// transports that make the SDK testable offline.
public struct P2PAppConfiguration: Sendable {
/// The app hostname (`<name>.ap2p.ro` or a custom domain).
public var host: String
/// Namespaces room ids and keys; never reuse another app's salt.
public var appSalt: String
/// Sent as the `Origin` header on the WebSocket upgrade and credential mints.
/// Required when the app's origin allowlist is non-empty.
public var origin: String?
public var descriptorTimeout: TimeInterval = 3
/// Use only TURN-relayed ICE candidates (tests the fallback path).
public var forceRelay = false
/// Reconnect rooms when the app returns to the foreground.
public var observeAppLifecycle = true
/// Explicit endpoint overrides (local development against the node servers).
public var endpoints: AppEndpoints?
public var http: any HTTPTransport = URLSessionHTTPTransport()
public var webSocket: any WebSocketTransport = URLSessionWebSocketTransport()
public var clock: any P2PClock = SystemClock()
/// Defaults to the WebRTC-backed factory.
public var peerConnections: (any PeerConnectionFactory)?
/// Mailbox tokens from your own backend; defaults to the node's open mint.
public var mailboxTokens: (any MailboxTokenProvider)?
public init(host: String, appSalt: String, origin: String? = nil) {
self.host = host
self.appSalt = appSalt
self.origin = origin
}
}
/// The entry point: discovers the app's descriptor, caches TURN credentials and
/// joins rooms.
public actor P2PApp {
public let configuration: P2PAppConfiguration
private let endpoints: AppEndpoints
private var descriptorCache: (descriptor: AppDescriptor, fetchedAt: Duration)?
private var descriptorFetch: Task<AppDescriptor, Never>?
private var iceCache: IceServerCache?
/// The self-minted token provider, shared by every mailbox of this app so its
/// app-scoped token is minted once, not once per room.
private var mailboxProvider: (mint: URL, provider: any MailboxTokenProvider)?
public init(host: String, appSalt: String, origin: String? = nil) {
self.init(configuration: P2PAppConfiguration(host: host, appSalt: appSalt, origin: origin))
}
public init(configuration: P2PAppConfiguration) {
self.configuration = configuration
endpoints = configuration.endpoints ?? .standard(host: configuration.host)
}
// MARK: descriptor
/// The app's `/.well-known/ap2p` descriptor; synthesized (`isFallback`) when unreachable.
/// A fallback is retried after 30 s, a real descriptor after an hour.
public func descriptor(refresh: Bool = false) async -> AppDescriptor {
if !refresh, let cached = descriptorCache {
let age = configuration.clock.now - cached.fetchedAt
if age < (cached.descriptor.isFallback ? .seconds(30) : .seconds(3600)) { return cached.descriptor }
}
if let running = descriptorFetch { return await running.value }
let task = Task { await self.fetchDescriptor() }
descriptorFetch = task
let d = await task.value
descriptorCache = (d, configuration.clock.now)
descriptorFetch = nil
return d
}
private func fetchDescriptor() async -> AppDescriptor {
do {
let response = try await configuration.http.send(.p2p(endpoints.descriptor, origin: configuration.origin, timeout: configuration.descriptorTimeout))
guard response.isSuccess else { throw P2PError.http(status: response.status, code: nil, body: response.body) }
return try AppDescriptor.decode(response.body)
} catch {
return AppDescriptor.fallback(host: configuration.host)
}
}
/// Resolved URLs for this app (overrides win over the descriptor).
public func resolvedEndpoints() async -> AppEndpoints.Resolved {
endpoints.resolved(with: await descriptor(), host: configuration.host)
}
// MARK: ICE
/// TURN credentials for `RTCPeerConnection`, cached and renewed; empty on failure.
public func iceConfiguration() async -> IceConfiguration {
if iceCache == nil {
let r = await resolvedEndpoints()
iceCache = IceServerCache(url: r.turnCredentials, origin: configuration.origin, http: configuration.http, clock: configuration.clock, relayOnly: configuration.forceRelay)
}
return await iceCache!.configuration()
}
// MARK: rooms
/// Joins the room derived from `secret`. Throws when the relay refuses the upgrade
/// (403 wrong or missing Origin, 404 unknown host) or the room is full.
public func join(secret: RoomSecret, identity: Identity? = nil, peerId: PeerId = .random(), options: RoomOptions = RoomOptions()) async throws -> Room {
let d = await descriptor()
let r = endpoints.resolved(with: d, host: configuration.host)
let crypto = RoomCrypto(secret: secret, appSalt: configuration.appSalt, peerId: peerId)
var relayConfig = RelaySession.Configuration(url: r.ws, origin: configuration.origin, room: crypto.roomId, peer: peerId, limits: d.limits)
relayConfig.keepaliveInterval = options.keepaliveInterval
relayConfig.pacing = options.pacing
let relay = RelaySession(configuration: relayConfig, transport: configuration.webSocket, clock: configuration.clock)
let factory: any PeerConnectionFactory = configuration.peerConnections ?? WebRTCPeerConnectionFactory()
let room = Room(crypto: crypto, relay: relay, identity: identity, appSalt: configuration.appSalt, options: options,
clock: configuration.clock, peerConnections: factory, ice: { [weak self] in await self?.iceConfiguration() ?? .none },
maxFrameBytes: d.limits.maxFrameBytes, observeLifecycle: configuration.observeAppLifecycle)
try await room.start(observeLifecycle: configuration.observeAppLifecycle)
return room
}
// MARK: mailbox
/// The raw mailbox client (opaque bytes). Throws `config` when the app has no mailbox,
/// or when its open mint is off and no token provider was injected.
public func rawMailbox(tokens: (any MailboxTokenProvider)? = nil) async throws -> MailboxClient {
let d = await descriptor()
let r = endpoints.resolved(with: d, host: configuration.host)
guard let endpoint = r.mailbox else { throw P2PError.config("the app has no mailbox enabled (no mailbox block in its descriptor)") }
let provider: any MailboxTokenProvider
if let tokens = tokens ?? configuration.mailboxTokens {
provider = tokens
} else if let mint = r.mailboxMint {
// injected providers above are the caller's to share; only our own is cached (per mint URL)
if mailboxProvider?.mint != mint {
mailboxProvider = (mint, OpenMintTokenProvider(mintURL: mint, origin: configuration.origin, http: configuration.http))
}
provider = mailboxProvider!.provider
} else {
throw P2PError.config("the app's mailbox open mint is off: inject a MailboxTokenProvider backed by your own backend")
}
return MailboxClient(endpoint: endpoint, tokens: provider, http: configuration.http, origin: configuration.origin)
}
/// The room-bound, end-to-end encrypted mailbox for `secret`.
public func mailbox(secret: RoomSecret, tokens: (any MailboxTokenProvider)? = nil) async throws -> SealedMailbox {
let client = try await rawMailbox(tokens: tokens)
return SealedMailbox(secret: secret, appSalt: configuration.appSalt, client: client, clock: configuration.clock)
}
}
|