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:
@@ -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
|
||||||
|
/// 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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user