Files
NianToB/electron/gateway/ws-client.ts
paisley c4f3749516 feat
2026-03-07 17:27:31 +08:00

123 lines
3.0 KiB
TypeScript

import WebSocket from 'ws';
import type { DeviceIdentity } from '../utils/device-identity';
import {
buildDeviceAuthPayload,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from '../utils/device-identity';
export async function probeGatewayReady(
port: number,
timeoutMs = 1500,
): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
let settled = false;
const resolveOnce = (value: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
testWs.close();
} catch {
// ignore
}
resolve(value);
};
const timeout = setTimeout(() => {
resolveOnce(false);
}, timeoutMs);
testWs.on('open', () => {
// Do not resolve on plain socket open. The gateway can accept the TCP/WebSocket
// connection before it is ready to issue protocol challenges, which previously
// caused a false "ready" result and then a full connect() stall.
});
testWs.on('message', (data) => {
try {
const message = JSON.parse(data.toString()) as { type?: string; event?: string };
if (message.type === 'event' && message.event === 'connect.challenge') {
resolveOnce(true);
}
} catch {
// ignore malformed probe payloads
}
});
testWs.on('error', () => {
resolveOnce(false);
});
testWs.on('close', () => {
resolveOnce(false);
});
});
}
export function buildGatewayConnectFrame(options: {
challengeNonce: string;
token: string;
deviceIdentity: DeviceIdentity | null;
platform: string;
}): { connectId: string; frame: Record<string, unknown> } {
const connectId = `connect-${Date.now()}`;
const role = 'operator';
const scopes = ['operator.admin'];
const signedAtMs = Date.now();
const clientId = 'gateway-client';
const clientMode = 'ui';
const device = (() => {
if (!options.deviceIdentity) return undefined;
const payload = buildDeviceAuthPayload({
deviceId: options.deviceIdentity.deviceId,
clientId,
clientMode,
role,
scopes,
signedAtMs,
token: options.token ?? null,
nonce: options.challengeNonce,
});
const signature = signDevicePayload(options.deviceIdentity.privateKeyPem, payload);
return {
id: options.deviceIdentity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem),
signature,
signedAt: signedAtMs,
nonce: options.challengeNonce,
};
})();
return {
connectId,
frame: {
type: 'req',
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: clientId,
displayName: 'ClawX',
version: '0.1.0',
platform: options.platform,
mode: clientMode,
},
auth: {
token: options.token,
},
caps: [],
role,
scopes,
device,
},
},
};
}