feat(gateway)!: switch handshake to req:connect (protocol v2)

This commit is contained in:
Peter Steinberger
2025-12-12 23:29:57 +00:00
parent e915ed182d
commit d5d80f4247
26 changed files with 586 additions and 955 deletions

View File

@@ -151,7 +151,7 @@ actor GatewayChannelActor {
self.task = self.session.makeWebSocketTask(url: self.url)
self.task?.resume()
do {
try await self.sendHello()
try await self.sendConnect()
} catch {
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
self.connected = false
@@ -176,40 +176,50 @@ actor GatewayChannelActor {
}
}
private func sendHello() async throws {
private func sendConnect() async throws {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
let clientName = InstanceIdentity.displayName
let hello = Hello(
type: "hello",
minprotocol: GATEWAY_PROTOCOL_VERSION,
maxprotocol: GATEWAY_PROTOCOL_VERSION,
client: [
"name": ClawdisProtocol.AnyCodable(clientName),
"version": ClawdisProtocol.AnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ClawdisProtocol.AnyCodable(platform),
"mode": ClawdisProtocol.AnyCodable("app"),
"instanceId": ClawdisProtocol.AnyCodable(InstanceIdentity.instanceId),
],
caps: [],
auth: self.token.map { ["token": ClawdisProtocol.AnyCodable($0)] },
locale: primaryLocale,
useragent: ProcessInfo.processInfo.operatingSystemVersionString)
let data = try JSONEncoder().encode(hello)
let reqId = UUID().uuidString
let client: [String: ProtoAnyCodable] = [
"name": ProtoAnyCodable(clientName),
"version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
"platform": ProtoAnyCodable(platform),
"mode": ProtoAnyCodable("app"),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
]
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([] as [String]),
"locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
]
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
}
let frame = RequestFrame(
type: "req",
id: reqId,
method: "connect",
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await self.task?.send(.data(data))
guard let msg = try await task?.receive() else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (no response)"])
userInfo: [NSLocalizedDescriptionKey: "connect failed (no response)"])
}
try await self.handleHelloResponse(msg)
try await self.handleConnectResponse(msg, reqId: reqId)
}
private func handleHelloResponse(_ msg: URLSessionWebSocketTask.Message) async throws {
private func handleConnectResponse(_ msg: URLSessionWebSocketTask.Message, reqId: String) async throws {
let data: Data? = switch msg {
case let .data(d): d
case let .string(s): s.data(using: .utf8)
@@ -219,37 +229,46 @@ actor GatewayChannelActor {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (empty response)"])
userInfo: [NSLocalizedDescriptionKey: "connect failed (empty response)"])
}
let decoder = JSONDecoder()
if let ok = try? decoder.decode(HelloOk.self, from: data) {
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
self.tickIntervalMs = tick
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
self.lastTick = Date()
self.tickTask?.cancel()
self.tickTask = Task { [weak self] in
guard let self else { return }
await self.watchTicks()
}
await self.pushHandler?(.snapshot(ok))
return
}
if let err = try? decoder.decode(HelloError.self, from: data) {
let reason = err.reason
// Log and throw a detailed error so UI can surface token/hello issues.
self.logger.error("gateway hello-error: \(reason, privacy: .public)")
guard let frame = try? decoder.decode(GatewayFrame.self, from: data) else {
throw NSError(
domain: "Gateway",
code: 1008,
userInfo: [NSLocalizedDescriptionKey: "hello-error: \(reason)"])
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (invalid response)"])
}
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "hello failed (unexpected response)"])
guard case let .res(res) = frame, res.id == reqId else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (unexpected response)"])
}
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
}
guard let payload = res.payload else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "connect failed (missing payload)"])
}
let payloadData = try self.encoder.encode(payload)
let ok = try decoder.decode(HelloOk.self, from: payloadData)
if let tick = ok.policy["tickIntervalMs"]?.value as? Double {
self.tickIntervalMs = tick
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
self.tickIntervalMs = Double(tick)
}
self.lastTick = Date()
self.tickTask?.cancel()
self.tickTask = Task { [weak self] in
guard let self else { return }
await self.watchTicks()
}
await self.pushHandler?(.snapshot(ok))
return
}
private func listen() {
@@ -301,9 +320,6 @@ actor GatewayChannelActor {
}
if evt.event == "tick" { self.lastTick = Date() }
await self.pushHandler?(.event(evt))
case let .helloOk(ok):
self.lastTick = Date()
await self.pushHandler?(.snapshot(ok))
default:
break
}

View File

@@ -51,11 +51,11 @@ class GatewaySocket {
this.ws = ws;
ws.onopen = () => {
logStatus(`ws: open -> sending hello (${this.url})`);
const hello = {
type: "hello",
minProtocol: 1,
maxProtocol: 1,
const id = randomId();
logStatus(`ws: open -> sending connect (${this.url})`);
const params = {
minProtocol: 2,
maxProtocol: 2,
client: {
name: "webchat-ui",
version: "dev",
@@ -63,8 +63,10 @@ class GatewaySocket {
mode: "webchat",
instanceId: randomId(),
},
caps: [],
};
ws.send(JSON.stringify(hello));
ws.send(JSON.stringify({ type: "req", id, method: "connect", params }));
this.pending.set(id, { resolve, reject, _handshake: true });
};
ws.onerror = (err) => {
@@ -91,14 +93,6 @@ class GatewaySocket {
} catch {
return;
}
if (msg.type === "hello-ok") {
logStatus(
`ws: hello-ok presence=${msg?.snapshot?.presence?.length ?? 0} healthOk=${msg?.snapshot?.health?.ok ?? "n/a"}`,
);
this.handlers.set("snapshot", msg.snapshot);
resolve(msg);
return;
}
if (msg.type === "event") {
const cb = this.handlers.get(msg.event);
if (cb) cb(msg.payload, msg);
@@ -108,8 +102,20 @@ class GatewaySocket {
const pending = this.pending.get(msg.id);
if (!pending) return;
this.pending.delete(msg.id);
if (msg.ok) pending.resolve(msg.payload);
else pending.reject(new Error(msg.error?.message || "gateway error"));
if (msg.ok) {
if (pending._handshake) {
const helloOk = msg.payload;
logStatus(
`ws: hello-ok presence=${helloOk?.snapshot?.presence?.length ?? 0} healthOk=${helloOk?.snapshot?.health?.ok ?? "n/a"}`,
);
this.handlers.set("snapshot", helloOk.snapshot);
pending.resolve(helloOk);
} else {
pending.resolve(msg.payload);
}
} else {
pending.reject(new Error(msg.error?.message || "gateway error"));
}
}
};
});

View File

@@ -196394,20 +196394,31 @@ var GatewaySocket = class {
const ws = new WebSocket(this.url);
this.ws = ws;
ws.onopen = () => {
logStatus(`ws: open -> sending hello (${this.url})`);
const hello = {
type: "hello",
minProtocol: 1,
maxProtocol: 1,
const id = randomId();
logStatus(`ws: open -> sending connect (${this.url})`);
const params = {
minProtocol: 2,
maxProtocol: 2,
client: {
name: "webchat-ui",
version: "dev",
platform: "browser",
mode: "webchat",
instanceId: randomId()
}
},
caps: []
};
ws.send(JSON.stringify(hello));
ws.send(JSON.stringify({
type: "req",
id,
method: "connect",
params
}));
this.pending.set(id, {
resolve,
reject,
_handshake: true
});
};
ws.onerror = (err) => {
logStatus(`ws: error ${formatError(err)}`);
@@ -196428,12 +196439,6 @@ var GatewaySocket = class {
} catch {
return;
}
if (msg.type === "hello-ok") {
logStatus(`ws: hello-ok presence=${msg?.snapshot?.presence?.length ?? 0} healthOk=${msg?.snapshot?.health?.ok ?? "n/a"}`);
this.handlers.set("snapshot", msg.snapshot);
resolve(msg);
return;
}
if (msg.type === "event") {
const cb = this.handlers.get(msg.event);
if (cb) cb(msg.payload, msg);
@@ -196443,8 +196448,18 @@ var GatewaySocket = class {
const pending = this.pending.get(msg.id);
if (!pending) return;
this.pending.delete(msg.id);
if (msg.ok) pending.resolve(msg.payload);
else pending.reject(new Error(msg.error?.message || "gateway error"));
if (msg.ok) {
if (pending._handshake) {
const helloOk = msg.payload;
logStatus(`ws: hello-ok presence=${helloOk?.snapshot?.presence?.length ?? 0} healthOk=${helloOk?.snapshot?.health?.ok ?? "n/a"}`);
this.handlers.set("snapshot", helloOk.snapshot);
pending.resolve(helloOk);
} else {
pending.resolve(msg.payload);
}
} else {
pending.reject(new Error(msg.error?.message || "gateway error"));
}
}
};
});