123 lines
3.0 KiB
TypeScript
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,
|
|
},
|
|
},
|
|
};
|
|
}
|