From cb663ae84e39e0bb61f7f0a9331d4dcf73dc7204 Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 02:54:39 +0100 Subject: [PATCH] feat(collab): Hocuspocus WebSocket connection scaffold Transport-layer foundation only per ARCHITECTURE.md section 2: connection lifecycle (connect/disconnect/reconnect) against wss:///collaboration/document. with Bearer auth, handing raw binary frames to a caller-supplied handler. Does not speak the Yjs sync/awareness wire protocol and does not touch a Y.Doc - that needs the YSwift package (not yet added; add via Xcode's Package Dependencies pointed at y-crdt/yswift and confirm it resolves before building on top of this) plus the ProseMirror<->markdown schema mapping in ARCHITECTURE.md section 4. Not a working collaborative editor yet - the socket handshake only. --- .../Collaboration/HocuspocusConnection.swift | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 OutlineKit/Sources/OutlineKit/Collaboration/HocuspocusConnection.swift 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 + } +}