home-assistant-js-websocket/lib/socket.ts

143 lines
4.1 KiB
TypeScript

/**
* Create a web socket connection with a Home Assistant instance.
*/
import {
ERR_INVALID_AUTH,
ERR_CANNOT_CONNECT,
ERR_HASS_HOST_REQUIRED,
} from "./errors.js";
import { Error } from "./types.js";
import type { ConnectionOptions } from "./connection.js";
import * as messages from "./messages.js";
import { atLeastHaVersion } from "./util.js";
const DEBUG = false;
export const MSG_TYPE_AUTH_REQUIRED = "auth_required";
export const MSG_TYPE_AUTH_INVALID = "auth_invalid";
export const MSG_TYPE_AUTH_OK = "auth_ok";
export interface HaWebSocket extends WebSocket {
haVersion: string;
}
export function createSocket(options: ConnectionOptions): Promise<HaWebSocket> {
if (!options.auth) {
throw ERR_HASS_HOST_REQUIRED;
}
const auth = options.auth;
// Start refreshing expired tokens even before the WS connection is open.
// We know that we will need auth anyway.
let authRefreshTask = auth.expired
? auth.refreshAccessToken().then(
() => {
authRefreshTask = undefined;
},
() => {
authRefreshTask = undefined;
},
)
: undefined;
// Convert from http:// -> ws://, https:// -> wss://
const url = auth.wsUrl;
if (DEBUG) {
console.log("[Auth phase] Initializing", url);
}
function connect(
triesLeft: number,
promResolve: (socket: HaWebSocket) => void,
promReject: (err: Error) => void,
) {
if (DEBUG) {
console.log("[Auth Phase] New connection", url);
}
const socket = new WebSocket(url) as HaWebSocket;
// If invalid auth, we will not try to reconnect.
let invalidAuth = false;
const closeMessage = () => {
// If we are in error handler make sure close handler doesn't also fire.
socket.removeEventListener("close", closeMessage);
if (invalidAuth) {
promReject(ERR_INVALID_AUTH);
return;
}
// Reject if we no longer have to retry
if (triesLeft === 0) {
// We never were connected and will not retry
promReject(ERR_CANNOT_CONNECT);
return;
}
const newTries = triesLeft === -1 ? -1 : triesLeft - 1;
// Try again in a second
setTimeout(() => connect(newTries, promResolve, promReject), 1000);
};
// Auth is mandatory, so we can send the auth message right away.
const handleOpen = async (event: MessageEventInit) => {
try {
if (auth.expired) {
await (authRefreshTask ? authRefreshTask : auth.refreshAccessToken());
}
socket.send(JSON.stringify(messages.auth(auth.accessToken)));
} catch (err) {
// Refresh token failed
invalidAuth = err === ERR_INVALID_AUTH;
socket.close();
}
};
const handleMessage = async (event: MessageEvent) => {
const message = JSON.parse(event.data);
if (DEBUG) {
console.log("[Auth phase] Received", message);
}
switch (message.type) {
case MSG_TYPE_AUTH_INVALID:
invalidAuth = true;
socket.close();
break;
case MSG_TYPE_AUTH_OK:
socket.removeEventListener("open", handleOpen);
socket.removeEventListener("message", handleMessage);
socket.removeEventListener("close", closeMessage);
socket.removeEventListener("error", closeMessage);
socket.haVersion = message.ha_version;
if (atLeastHaVersion(socket.haVersion, 2022, 9)) {
socket.send(JSON.stringify(messages.supportedFeatures()));
}
promResolve(socket);
break;
default:
if (DEBUG) {
// We already send response to this message when socket opens
if (message.type !== MSG_TYPE_AUTH_REQUIRED) {
console.warn("[Auth phase] Unhandled message", message);
}
}
}
};
socket.addEventListener("open", handleOpen);
socket.addEventListener("message", handleMessage);
socket.addEventListener("close", closeMessage);
socket.addEventListener("error", closeMessage);
}
return new Promise((resolve, reject) =>
connect(options.setupRetry, resolve, reject),
);
}