Files

135 lines
4.8 KiB
JavaScript
Raw Permalink Normal View History

import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs';
import { z } from 'zod';
const optionalIdentity = z.string().catch('').transform((value) => value.trim());
const versionEnvelope = z.object({
status: z.literal('ok'),
// Arrays historically classify as incompatible rather than wrong-service.
compatibility: z.union([z.looseObject({}), z.array(z.unknown())]),
});
const buildProbeUrl = (url, pathname) => {
try {
const parsed = new URL(url);
parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}${pathname}`;
return parsed.toString();
} catch {
return null;
}
};
const classifyVersionPayload = (payload) => {
const parsed = versionEnvelope.safeParse(payload);
if (!parsed.success) {
return 'wrong-service';
}
const { compatibility } = parsed.data;
if (!Array.isArray(compatibility.capabilities) || !compatibility.capabilities.includes('api.runtime-url.v1')) {
return 'incompatible';
}
if (compatibility.apiVersion !== 1 || compatibility.minClientApiVersion > 1) {
return 'update-recommended';
}
return 'ok';
};
export const probeElectronHostWithDeadline = async ({
url,
timeoutMs,
clientToken = '',
requestHeaders = {},
expectedServerId = '',
chromiumFetch,
isReady,
now = Date.now,
scheduleTimeout = setTimeout,
cancelTimeout = clearTimeout,
}) => {
const started = now();
const result = (status) => ({ status, latencyMs: now() - started });
if (!isReady()) return result('unreachable');
const versionUrl = buildProbeUrl(url, '/api/version');
const sessionUrl = buildProbeUrl(url, '/auth/session');
if (!versionUrl || !sessionUrl) throw new Error('Invalid URL');
const controller = new AbortController();
let rejectDeadline;
const deadline = new Promise((_, reject) => {
rejectDeadline = reject;
});
const timer = scheduleTimeout(() => {
controller.abort();
rejectDeadline(new Error('Host probe deadline exceeded'));
}, timeoutMs);
const responses = new Set();
const discardBody = async (response) => {
if (response.body && !response.body.locked) await response.body.cancel().catch(() => {});
};
const fetchProbe = async (requestUrl, headers) => {
controller.signal.throwIfAborted();
const response = await chromiumFetch(requestUrl, {
headers,
signal: controller.signal,
redirect: 'manual',
});
if (controller.signal.aborted) {
await discardBody(response);
controller.signal.throwIfAborted();
}
responses.add(response);
return response;
};
const run = async () => {
const expectedIdentity = optionalIdentity.parse(expectedServerId);
if (expectedIdentity) {
const healthUrl = buildProbeUrl(url, '/health');
if (healthUrl) {
try {
const response = await fetchProbe(healthUrl, { Accept: 'application/json' });
// A redirected identity belongs to another candidate, even if its ID matches.
if (response.status >= 300 && response.status < 400 || response.redirected) return result('wrong-service');
if (response.ok) {
const payload = await response.json().catch(() => null);
const reported = optionalIdentity.parse(payload?.serverId);
if (reported && reported !== expectedIdentity) return result('wrong-service');
}
} catch (error) {
if (controller.signal.aborted) throw error;
// Electron 43 net.fetch rejects manual redirects instead of returning a 3xx response.
if (error instanceof Error && error.message === 'Redirect was cancelled') return result('wrong-service');
// Identity is optional on older servers; the authenticated request remains authoritative.
}
}
}
if (controller.signal.aborted) throw new Error('Host probe deadline exceeded');
const headers = { ...sanitizeRuntimeRequestHeaders(requestHeaders), Accept: 'application/json' };
const token = optionalIdentity.parse(clientToken);
if (token) headers.Authorization = `Bearer ${token}`;
const versionResponse = await fetchProbe(versionUrl, headers);
if (versionResponse.status === 401 || versionResponse.status === 403) return result('auth');
if (!versionResponse.ok) return result('unreachable');
const versionStatus = classifyVersionPayload(await versionResponse.json().catch(() => null));
if (versionStatus !== 'ok') return result(versionStatus);
const sessionResponse = await fetchProbe(sessionUrl, headers);
if (sessionResponse.status === 401 || sessionResponse.status === 403) return result('auth');
if (!sessionResponse.ok) return result('unreachable');
return result('ok');
};
try {
return await Promise.race([run(), deadline]);
} catch {
return result('unreachable');
} finally {
controller.abort();
await Promise.allSettled([...responses].map(discardBody));
cancelTimeout(timer);
}
};