This commit is contained in:
paisley
2026-03-07 18:01:11 +08:00
parent 7b3dadd8b0
commit 9ce6aea026
3 changed files with 218 additions and 163 deletions

View File

@@ -1,10 +1,12 @@
import WebSocket from 'ws';
import type { DeviceIdentity } from '../utils/device-identity';
import type { PendingGatewayRequest } from './request-store';
import {
buildDeviceAuthPayload,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from '../utils/device-identity';
import { logger } from '../utils/logger';
export async function probeGatewayReady(
port: number,
@@ -120,3 +122,160 @@ export function buildGatewayConnectFrame(options: {
},
};
}
export async function connectGatewaySocket(options: {
port: number;
deviceIdentity: DeviceIdentity | null;
platform: string;
pendingRequests: Map<string, PendingGatewayRequest>;
getToken: () => Promise<string>;
onHandshakeComplete: (ws: WebSocket) => void;
onMessage: (message: unknown) => void;
onCloseAfterHandshake: () => void;
}): Promise<WebSocket> {
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
return await new Promise<WebSocket>((resolve, reject) => {
const wsUrl = `ws://localhost:${options.port}/ws`;
const ws = new WebSocket(wsUrl);
let handshakeComplete = false;
let connectId: string | null = null;
let handshakeTimeout: NodeJS.Timeout | null = null;
let challengeTimer: NodeJS.Timeout | null = null;
let challengeReceived = false;
let settled = false;
const cleanupHandshakeRequest = () => {
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
if (handshakeTimeout) {
clearTimeout(handshakeTimeout);
handshakeTimeout = null;
}
if (connectId && options.pendingRequests.has(connectId)) {
const request = options.pendingRequests.get(connectId);
if (request) {
clearTimeout(request.timeout);
}
options.pendingRequests.delete(connectId);
}
};
const resolveOnce = () => {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
resolve(ws);
};
const rejectOnce = (error: unknown) => {
if (settled) return;
settled = true;
cleanupHandshakeRequest();
reject(error instanceof Error ? error : new Error(String(error)));
};
const sendConnectHandshake = async (challengeNonce: string) => {
logger.debug('Sending connect handshake with challenge nonce');
const currentToken = await options.getToken();
const connectPayload = buildGatewayConnectFrame({
challengeNonce,
token: currentToken,
deviceIdentity: options.deviceIdentity,
platform: options.platform,
});
connectId = connectPayload.connectId;
ws.send(JSON.stringify(connectPayload.frame));
const requestTimeout = setTimeout(() => {
if (!handshakeComplete) {
logger.error('Gateway connect handshake timed out');
ws.close();
rejectOnce(new Error('Connect handshake timeout'));
}
}, 10000);
handshakeTimeout = requestTimeout;
options.pendingRequests.set(connectId, {
resolve: () => {
handshakeComplete = true;
logger.debug('Gateway connect handshake completed');
options.onHandshakeComplete(ws);
resolveOnce();
},
reject: (error) => {
logger.error('Gateway connect handshake failed:', error);
rejectOnce(error);
},
timeout: requestTimeout,
});
};
challengeTimer = setTimeout(() => {
if (!challengeReceived && !settled) {
logger.error('Gateway connect.challenge not received within timeout');
ws.close();
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
}
}, 10000);
ws.on('open', () => {
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
if (
!challengeReceived &&
typeof message === 'object' && message !== null &&
message.type === 'event' && message.event === 'connect.challenge'
) {
challengeReceived = true;
if (challengeTimer) {
clearTimeout(challengeTimer);
challengeTimer = null;
}
const nonce = message.payload?.nonce as string | undefined;
if (!nonce) {
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
return;
}
logger.debug('Received connect.challenge, sending handshake');
void sendConnectHandshake(nonce);
return;
}
options.onMessage(message);
} catch (error) {
logger.debug('Failed to parse Gateway WebSocket message:', error);
}
});
ws.on('close', (code, reason) => {
const reasonStr = reason?.toString() || 'unknown';
logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`);
if (!handshakeComplete) {
rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`));
return;
}
cleanupHandshakeRequest();
options.onCloseAfterHandshake();
});
ws.on('error', (error) => {
if (error.message?.includes('closed before handshake') || (error as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
logger.debug(`Gateway WebSocket connection error (transient): ${error.message}`);
} else {
logger.error('Gateway WebSocket error:', error);
}
if (!handshakeComplete) {
rejectOnce(error);
}
});
});
}