diff --git a/OutlineKit/Sources/OutlineKit/Collaboration/HocuspocusConnection.swift b/OutlineKit/Sources/OutlineKit/Collaboration/HocuspocusConnection.swift new file mode 100644 index 0000000..962b24b --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Collaboration/HocuspocusConnection.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Connection lifecycle for Outline's Hocuspocus collaboration socket +/// (`wss:///collaboration/document.`) — see `docs/ARCHITECTURE.md` +/// section 2. This is transport only: it opens the socket, authenticates, +/// and hands raw binary frames to a handler. It does **not** speak the Yjs +/// sync/awareness wire protocol or touch a `Y.Doc` — that needs the YSwift +/// package (not yet added to this project; add it via Xcode's Package +/// Dependencies, pointed at `https://github.com/y-crdt/yswift`, and confirm +/// it resolves/builds before writing anything on top of this) plus the +/// ProseMirror⇄markdown schema mapping described in ARCHITECTURE.md +/// section 4, neither of which exist yet. Do not treat this class as "collab +/// is working" — it's the socket handshake only. +public actor HocuspocusConnection { + public enum State: Equatable, Sendable { + case disconnected + case connecting + case connected + case failed(String) + } + + public private(set) var state: State = .disconnected + + private let documentId: String + private let baseURL: URL + private let tokenStore: TokenStoring + private var task: URLSessionWebSocketTask? + private var receiveLoopTask: Task? + private var onFrame: (@Sendable (Data) -> Void)? + + public init(documentId: String, baseURL: URL, tokenStore: TokenStoring) { + self.documentId = documentId + self.baseURL = baseURL + self.tokenStore = tokenStore + } + + /// `onFrame` is called for every binary frame received, on an arbitrary + /// executor — callers that touch UI state must hop back to `@MainActor` + /// themselves. + public func connect(onFrame: @escaping @Sendable (Data) -> Void) { + guard state != .connected, state != .connecting else { return } + self.onFrame = onFrame + + guard let token = try? tokenStore.token(), let wsURL = webSocketURL() else { + state = .failed("Missing token or invalid server URL.") + return + } + + state = .connecting + var request = URLRequest(url: wsURL) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let session = URLSession(configuration: .default) + let webSocketTask = session.webSocketTask(with: request) + task = webSocketTask + webSocketTask.resume() + state = .connected + + receiveLoopTask = Task { [weak self] in + await self?.receiveLoop() + } + } + + public func disconnect() { + receiveLoopTask?.cancel() + receiveLoopTask = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil + state = .disconnected + } + + public func send(_ data: Data) async throws { + guard let task else { return } + try await task.send(.data(data)) + } + + private func receiveLoop() async { + guard let task else { return } + while !Task.isCancelled { + do { + let message = try await task.receive() + switch message { + case .data(let data): + onFrame?(data) + case .string(let text): + onFrame?(Data(text.utf8)) + @unknown default: + break + } + } catch { + state = .failed(error.localizedDescription) + return + } + } + } + + private func webSocketURL() -> URL? { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { return nil } + components.scheme = components.scheme == "http" ? "ws" : "wss" + components.path = "/collaboration/document.\(documentId)" + return components.url + } +}