Files
openchamber/packages/web/bin/lib/cli-http.js
T

253 lines
7.9 KiB
JavaScript
Raw Normal View History

import { buildLocalUrl } from './cli-network.js';
import { readDesktopLocalClientTokenFromSettings, readDesktopLocalPortFromSettings } from './cli-paths.js';
import { getInstanceFilePath, readInstanceOptions } from './cli-process.js';
const UI_SESSION_COOKIE_NAME = 'oc_ui_session';
function extractUiSessionCookie(response) {
const values = [];
const direct = response?.headers?.get?.('set-cookie');
if (typeof direct === 'string' && direct.length > 0) {
values.push(direct);
}
const getSetCookie = response?.headers?.getSetCookie;
if (typeof getSetCookie === 'function') {
const setCookies = getSetCookie.call(response.headers);
if (Array.isArray(setCookies)) {
values.push(...setCookies.filter((value) => typeof value === 'string' && value.length > 0));
}
}
const raw = response?.headers?.raw?.();
if (Array.isArray(raw?.['set-cookie'])) {
values.push(...raw['set-cookie'].filter((value) => typeof value === 'string' && value.length > 0));
}
for (const setCookie of values) {
const match = setCookie.match(new RegExp(`(?:^|,\\s*)(${UI_SESSION_COOKIE_NAME}=[^;]+)`));
if (match?.[1]) return match[1];
}
return null;
}
async function resolveUiPasswordForPort(port, options = {}) {
if (options.explicitUiPassword && typeof options.uiPassword === 'string' && options.uiPassword.trim().length > 0) {
return options.uiPassword;
}
const instanceOptions = readInstanceOptions(await getInstanceFilePath(port));
if (typeof instanceOptions?.uiPassword === 'string' && instanceOptions.uiPassword.trim().length > 0) {
return instanceOptions.uiPassword;
}
return typeof options.uiPassword === 'string' && options.uiPassword.trim().length > 0
? options.uiPassword
: null;
}
async function createUiSessionCookie(port, password, timeoutMs) {
if (typeof password !== 'string' || password.length === 0) {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(buildLocalUrl(port, '/auth/session'), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
signal: controller.signal,
});
if (!response.ok) {
return null;
}
return extractUiSessionCookie(response);
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function getDesktopLocalAuthHeader(port, requestHeaders) {
if (requestHeaders.Authorization || requestHeaders.authorization) {
return null;
}
const desktopPort = readDesktopLocalPortFromSettings();
if (desktopPort !== port) {
return null;
}
const token = readDesktopLocalClientTokenFromSettings();
return token ? `Bearer ${token}` : null;
}
async function requestServerShutdown(port, hostOverride) {
if (!Number.isFinite(port) || port <= 0) return false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const resp = await fetch(buildLocalUrl(port, '/api/system/shutdown', hostOverride), {
method: 'POST',
signal: controller.signal,
});
return resp.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function requestJson(port, endpoint, options = {}) {
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
? Math.trunc(options.timeoutMs)
: 4000;
const fetchOptions = { ...options };
delete fetchOptions.timeoutMs;
delete fetchOptions.uiPassword;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const requestUrl = buildLocalUrl(port, endpoint);
const requestHeaders = {
Accept: 'application/json',
...(fetchOptions.body ? { 'Content-Type': 'application/json' } : {}),
...(fetchOptions.headers || {}),
};
const desktopAuth = getDesktopLocalAuthHeader(port, requestHeaders);
if (desktopAuth) {
requestHeaders.Authorization = desktopAuth;
}
const response = await fetch(requestUrl, {
...fetchOptions,
headers: requestHeaders,
signal: controller.signal,
});
const body = await response.json().catch(() => null);
if (response.status === 401 && body?.error === 'UI authentication required') {
const uiPassword = await resolveUiPasswordForPort(port, options);
const cookie = await createUiSessionCookie(port, uiPassword, timeoutMs);
if (cookie) {
const retryResponse = await fetch(requestUrl, {
...fetchOptions,
headers: {
...requestHeaders,
Cookie: cookie,
},
signal: controller.signal,
});
const retryBody = await retryResponse.json().catch(() => null);
return { response: retryResponse, body: retryBody };
}
}
return { response, body };
} catch (error) {
if (error && (error.name === 'AbortError' || error.code === 'ABORT_ERR')) {
throw new Error(`Request to ${endpoint} timed out after ${timeoutMs}ms.`);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function isServerHealthReady(port, timeoutMs = 1000) {
if (!Number.isFinite(port) || port <= 0) {
return false;
}
const requestTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.trunc(timeoutMs) : 1000;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), requestTimeout);
try {
const response = await fetch(buildLocalUrl(port, '/health'), {
headers: { Accept: 'text/plain' },
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function waitForServerHealth(port, {
timeoutMs = 60000,
intervalMs = 250,
onTick,
} = {}) {
const start = Date.now();
const deadline = start + timeoutMs;
while (Date.now() < deadline) {
const elapsedMs = Date.now() - start;
if (typeof onTick === 'function') {
onTick({ elapsedMs, timeoutMs });
}
if (await isServerHealthReady(port, Math.min(1000, intervalMs * 2))) {
if (typeof onTick === 'function') {
onTick({ elapsedMs: Math.min(Date.now() - start, timeoutMs), timeoutMs, complete: true });
}
return true;
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
if (typeof onTick === 'function') {
onTick({ elapsedMs: timeoutMs, timeoutMs, timedOut: true });
}
return false;
}
async function fetchTunnelProvidersFromPort(port, fetchImpl = globalThis.fetch) {
if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') {
return null;
}
try {
const response = await fetchImpl(buildLocalUrl(port, '/api/openchamber/tunnel/providers'));
if (!response.ok) return null;
const body = await response.json().catch(() => null);
if (!body || !Array.isArray(body.providers)) return null;
return body.providers;
} catch {
return null;
}
}
async function fetchSystemInfoFromPort(port, fetchImpl = globalThis.fetch, hostOverride) {
if (!Number.isFinite(port) || port <= 0 || typeof fetchImpl !== 'function') {
return null;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
try {
const response = await fetchImpl(buildLocalUrl(port, '/api/system/info', hostOverride), {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) return null;
const body = await response.json().catch(() => null);
if (!body || typeof body.runtime !== 'string') return null;
return {
runtime: body.runtime,
pid: Number.isFinite(body.pid) ? body.pid : null,
};
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
export {
requestServerShutdown,
requestJson,
isServerHealthReady,
waitForServerHealth,
fetchTunnelProvidersFromPort,
fetchSystemInfoFromPort,
};