Adds electron/gateway-ws-probe.cjs: a small helper that opens a gateway WebSocket URL and classifies the handshake (open/frame → ok; error or close before open → fail; open-then-early-close → credential rejected; never-opens → timeout). The WebSocket implementation is injected so it can be unit-tested without a real socket. Wires gateway-ws-probe.test.cjs into test:desktop:platforms, covering every handshake outcome plus constructor-throw and missing-impl.
189 lines
6.2 KiB
JavaScript
189 lines
6.2 KiB
JavaScript
/**
|
|
* Live WebSocket validation for the remote-gateway "Test remote" button.
|
|
*
|
|
* Background: the desktop boot does two independent things to a remote gateway:
|
|
*
|
|
* 1. The MAIN process hits ``GET /api/status`` over HTTP (token in a header)
|
|
* to confirm the backend is up. This is what "Test remote" historically
|
|
* checked, and what the boot logs print as "Remote Hermes backend is
|
|
* ready".
|
|
* 2. The RENDERER then opens a live WebSocket to ``/api/ws`` (credential in a
|
|
* query param) via ``gateway.connect()``. The chat surface only works once
|
|
* THIS succeeds.
|
|
*
|
|
* Those two paths use different processes, transports, and credentials, and the
|
|
* server applies extra guards to the WS upgrade that the HTTP status route never
|
|
* sees (Host/Origin checks, ws-ticket/token auth, peer-IP checks). So a gateway
|
|
* can pass the HTTP status check yet reject the WebSocket — which surfaces to
|
|
* the user as a green "Test remote" followed by an opaque "Could not connect to
|
|
* Hermes gateway" on the boot overlay.
|
|
*
|
|
* This module performs the second half of the check: it actually opens the WS
|
|
* URL and confirms the upgrade is accepted (and isn't immediately torn down by
|
|
* a post-upgrade auth rejection). The ``WebSocketImpl`` is injectable so the
|
|
* unit tests can drive the handshake without a real socket; in production the
|
|
* caller passes the Node/Electron global ``WebSocket``.
|
|
*/
|
|
|
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
// After the upgrade is accepted, a gateway that rejects the credential
|
|
// post-handshake closes the socket almost immediately. Wait a short grace
|
|
// window: a frame (gateway.ready) or a still-open socket means success; an
|
|
// early close means the upgrade was accepted but the session was refused.
|
|
const DEFAULT_READY_GRACE_MS = 750
|
|
|
|
/**
|
|
* Attempt a live WebSocket connection and classify the outcome.
|
|
*
|
|
* @param {string} wsUrl - Fully-formed ws(s):// URL including the credential.
|
|
* @param {object} [options]
|
|
* @param {new (url: string) => any} [options.WebSocketImpl] - WebSocket ctor.
|
|
* @param {number} [options.connectTimeoutMs]
|
|
* @param {number} [options.readyGraceMs]
|
|
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
|
*/
|
|
function probeGatewayWebSocket(wsUrl, options = {}) {
|
|
const WebSocketImpl = options.WebSocketImpl
|
|
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
const readyGraceMs = options.readyGraceMs ?? DEFAULT_READY_GRACE_MS
|
|
|
|
if (typeof WebSocketImpl !== 'function') {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
reason: 'WebSocket is not available in this runtime.'
|
|
})
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
let settled = false
|
|
let opened = false
|
|
let connectTimer = null
|
|
let graceTimer = null
|
|
let socket
|
|
|
|
const clearTimers = () => {
|
|
if (connectTimer !== null) {
|
|
clearTimeout(connectTimer)
|
|
connectTimer = null
|
|
}
|
|
if (graceTimer !== null) {
|
|
clearTimeout(graceTimer)
|
|
graceTimer = null
|
|
}
|
|
}
|
|
|
|
const finish = result => {
|
|
if (settled) return
|
|
settled = true
|
|
clearTimers()
|
|
try {
|
|
socket?.close?.()
|
|
} catch {
|
|
// ignore — best effort teardown
|
|
}
|
|
resolve(result)
|
|
}
|
|
|
|
try {
|
|
socket = new WebSocketImpl(wsUrl)
|
|
} catch (error) {
|
|
finish({
|
|
ok: false,
|
|
reason: error instanceof Error ? error.message : String(error)
|
|
})
|
|
return
|
|
}
|
|
|
|
const onOpen = () => {
|
|
if (settled) return
|
|
opened = true
|
|
// Upgrade accepted. Give the server a brief window to reject the
|
|
// credential post-handshake (early close) before declaring success.
|
|
graceTimer = setTimeout(() => {
|
|
finish({ ok: true })
|
|
}, readyGraceMs)
|
|
}
|
|
|
|
const onMessage = () => {
|
|
// Any frame means the gateway accepted us and is talking — unambiguous
|
|
// success, no need to wait out the grace window.
|
|
finish({ ok: true })
|
|
}
|
|
|
|
const onError = event => {
|
|
finish({
|
|
ok: false,
|
|
reason: extractErrorReason(event) || 'WebSocket connection failed.'
|
|
})
|
|
}
|
|
|
|
const onClose = event => {
|
|
if (settled) return
|
|
if (opened) {
|
|
// Opened, then closed inside the grace window: the upgrade was accepted
|
|
// but the session was refused (e.g. ws-ticket/token rejected, or a
|
|
// server-side Host/Origin guard tripped after accept).
|
|
finish({
|
|
ok: false,
|
|
reason: closeReason(event, 'The gateway accepted the connection then closed it (credential rejected?).')
|
|
})
|
|
return
|
|
}
|
|
finish({
|
|
ok: false,
|
|
reason: closeReason(event, 'The gateway closed the WebSocket before it opened.')
|
|
})
|
|
}
|
|
|
|
addListener(socket, 'open', onOpen)
|
|
addListener(socket, 'message', onMessage)
|
|
addListener(socket, 'error', onError)
|
|
addListener(socket, 'close', onClose)
|
|
|
|
if (connectTimeoutMs > 0) {
|
|
connectTimer = setTimeout(() => {
|
|
finish({
|
|
ok: false,
|
|
reason: `Timed out after ${connectTimeoutMs}ms waiting for the WebSocket to open.`
|
|
})
|
|
}, connectTimeoutMs)
|
|
}
|
|
})
|
|
}
|
|
|
|
function addListener(socket, type, handler) {
|
|
if (typeof socket.addEventListener === 'function') {
|
|
socket.addEventListener(type, handler)
|
|
return
|
|
}
|
|
// Node's global WebSocket implements addEventListener; this fallback keeps the
|
|
// helper usable with the `ws` package's EventEmitter shape too.
|
|
if (typeof socket.on === 'function') {
|
|
socket.on(type, handler)
|
|
}
|
|
}
|
|
|
|
function extractErrorReason(event) {
|
|
if (!event) return ''
|
|
if (event instanceof Error) return event.message
|
|
const err = event.error || event.message
|
|
if (err instanceof Error) return err.message
|
|
if (typeof err === 'string') return err
|
|
return ''
|
|
}
|
|
|
|
function closeReason(event, fallback) {
|
|
const code = event && typeof event.code === 'number' ? event.code : null
|
|
const reason = event && typeof event.reason === 'string' ? event.reason.trim() : ''
|
|
if (code && reason) return `${fallback} (code ${code}: ${reason})`
|
|
if (code) return `${fallback} (code ${code})`
|
|
if (reason) return `${fallback} (${reason})`
|
|
return fallback
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
DEFAULT_READY_GRACE_MS,
|
|
probeGatewayWebSocket
|
|
}
|