Merge pull request 'fix: refresh terminal websocket auth on reconnect' (#32) from dev into main
Deploy Dashboard / deploy (push) Successful in 1m13s

This commit was merged in pull request #32.
This commit is contained in:
2026-03-07 17:57:47 +08:00
2 changed files with 68 additions and 7 deletions
+9
View File
@@ -54,6 +54,15 @@ async function tryRefresh() {
return refreshPromise; return refreshPromise;
} }
export async function getWebSocketToken(forceRefresh = false) {
if (!forceRefresh && token) return token;
const refreshed = await tryRefresh();
if (refreshed && token) return token;
setToken("");
setRefreshToken("");
return "";
}
async function request(path, opts = {}) { async function request(path, opts = {}) {
const headers = opts.headers || {}; const headers = opts.headers || {};
if (token) headers["Authorization"] = `Bearer ${token}`; if (token) headers["Authorization"] = `Bearer ${token}`;
+59 -7
View File
@@ -1,7 +1,7 @@
<script> <script>
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../lib/voice.js"; import { VoiceSession } from "../lib/voice.js";
import { getToken } from "../lib/api.js"; import { getWebSocketToken } from "../lib/api.js";
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:"; const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX); const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
@@ -189,11 +189,11 @@
fit.fit(); fit.fit();
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = getToken() || "";
let heartbeat = null; let heartbeat = null;
let reconnectTimer = null; let reconnectTimer = null;
let closedManually = false; let closedManually = false;
let reconnecting = false; let reconnecting = false;
let authRecoveryInFlight = false;
function clearHeartbeat() { function clearHeartbeat() {
if (heartbeat) { if (heartbeat) {
@@ -202,6 +202,29 @@
} }
} }
function handleAuthExpired(reason = "Session expired — please log in again") {
clearHeartbeat();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnecting = false;
updateTab(tabId, {
connected: false,
error: reason,
statusBanner: "",
});
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
const d = tabData.get(tabId);
if (d) {
d.ws = null;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.reconnecting = reconnecting;
}
}
function scheduleReconnect(reason) { function scheduleReconnect(reason) {
clearHeartbeat(); clearHeartbeat();
if (closedManually || reconnectTimer) return; if (closedManually || reconnectTimer) return;
@@ -215,7 +238,7 @@
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
reconnectTimer = null; reconnectTimer = null;
if (!tabData.has(tabId) || closedManually) return; if (!tabData.has(tabId) || closedManually) return;
connect(); connect(true);
}, 2000); }, 2000);
} }
@@ -229,8 +252,13 @@
} }
} }
function connect() { async function connect(forceRefresh = false) {
const token = getToken() || ""; const token = await getWebSocketToken(forceRefresh);
if (!token) {
handleAuthExpired();
return null;
}
const ws = new WebSocket( const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}` `${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
); );
@@ -272,10 +300,30 @@
} }
term.write(data); term.write(data);
}; };
ws.onclose = (e) => { ws.onclose = async (e) => {
const reason = e.reason || `Connection closed (code ${e.code})`; const reason = e.reason || `Connection closed (code ${e.code})`;
const d = tabData.get(tabId); const d = tabData.get(tabId);
if (d) d.ws = null; if (d) d.ws = null;
if (e.code === 1008 && reason === "Unauthorized") {
if (!closedManually && !authRecoveryInFlight) {
authRecoveryInFlight = true;
const recoveredToken = await getWebSocketToken(true);
authRecoveryInFlight = false;
if (recoveredToken) {
reconnecting = true;
updateTab(tabId, {
connected: false,
error: "Refreshing session...",
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
});
term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
void connect(false);
return;
}
}
handleAuthExpired("Session expired — please log in again");
return;
}
if (!closedManually) scheduleReconnect(reason); if (!closedManually) scheduleReconnect(reason);
else { else {
clearHeartbeat(); clearHeartbeat();
@@ -301,7 +349,11 @@
return ws; return ws;
} }
let ws = connect(); let ws = null;
void connect().then((connectedWs) => {
ws = connectedWs;
if (connectedWs) voice.attach(term, connectedWs);
});
term.onData((data) => { term.onData((data) => {
const currentWs = tabData.get(tabId)?.ws; const currentWs = tabData.get(tabId)?.ws;
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data)); if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));