feat(collab): Hocuspocus WebSocket connection scaffold

Transport-layer foundation only per ARCHITECTURE.md section 2:
connection lifecycle (connect/disconnect/reconnect) against
wss://<host>/collaboration/document.<id> 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.
This commit is contained in:
2026-08-14 02:54:39 +01:00
parent 86e7aa5800
commit cb663ae84e
@@ -0,0 +1,103 @@
import Foundation
/// Connection lifecycle for Outline's Hocuspocus collaboration socket
/// (`wss://<host>/collaboration/document.<id>`) 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
/// ProseMirrormarkdown 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<Void, Never>?
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
}
}