UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

137 lines (136 loc) • 6.01 kB
export class NetworkError extends Error { code; status = 0; constructor(message, code, cause) { super(message, { cause }); this.code = code; this.name = 'NetworkError'; } } function errorCodes(error, seen = new Set()) { if (!error || typeof error !== 'object' || seen.has(error)) return []; seen.add(error); const item = error; return [ ...(typeof item.code === 'string' ? [item.code] : []), ...errorCodes(item.cause, seen), ...(Array.isArray(item.errors) ? item.errors.flatMap((e) => errorCodes(e, seen)) : []), ]; } export function networkError(error, url, timeoutMs, timedOut) { // Never echo query strings, URL credentials, request bodies, or raw causes. const endpoint = new URL(url).origin; const codes = errorCodes(error); const code = codes[0] ?? 'NETWORK_ERROR'; if (timedOut || codes.some((c) => /TIMEOUT|ETIMEDOUT/.test(c))) { return new NetworkError(`Request to ${endpoint} timed out after ${timeoutMs}ms. Check your connection or increase --timeout.`, 'TIMEOUT', error); } if (codes.some((c) => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(c))) { return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}). Use Node 22.19+ or Node 24.5+ for automatic system certificate trust. If your organization uses a private CA, configure NODE_EXTRA_CA_CERTS with its trusted PEM certificate.`, code, error); } if (codes.some((c) => c === 'ENOTFOUND' || c === 'EAI_AGAIN')) { return new NetworkError(`Cannot resolve ${endpoint} (${code}). Check DNS, your VPN, and the configured API URL.`, code, error); } return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL. Run \`vigilbase doctor\` for connectivity checks.`, code, error); } export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; /** Bound decoded bytes even when Content-Length is absent, compressed, or dishonest. */ export async function readBoundedBody(response, signal, maxBytes = MAX_RESPONSE_BYTES) { if (Number(response.headers.get('content-length')) > maxBytes) { await response.body?.cancel(); throw new NetworkError('Response body exceeds the size limit.', 'RESPONSE_TOO_LARGE'); } if (!response.body) return ''; const reader = response.body.getReader(); const abort = () => { void reader.cancel().catch(() => undefined); }; signal.addEventListener('abort', abort, { once: true }); let size = 0; const chunks = []; try { while (true) { if (signal.aborted) throw new Error('Aborted'); const { done, value } = await reader.read(); if (signal.aborted) throw new Error('Aborted'); if (done) return Buffer.concat(chunks).toString('utf8'); size += value.byteLength; if (size > maxBytes) { await reader.cancel(); throw new NetworkError('Response body exceeds the size limit.', 'RESPONSE_TOO_LARGE'); } chunks.push(value); } } finally { signal.removeEventListener('abort', abort); reader.releaseLock(); } } /** * A signal that aborts with the reason of the first of `signals` to abort. * Uses `AbortSignal.any` (Node 20.3+) and combines them by hand on older * Node 20; `dispose` then removes the listeners so a long-lived signal does * not collect one per request. */ export function anySignal(signals) { if (typeof AbortSignal.any === 'function') return { signal: AbortSignal.any(signals), dispose: () => undefined }; const controller = new AbortController(); const dispose = () => { for (const signal of signals) signal.removeEventListener('abort', onAbort); }; function onAbort(event) { dispose(); controller.abort(event.target.reason); } const aborted = signals.find((signal) => signal.aborted); if (aborted) { controller.abort(aborted.reason); return { signal: controller.signal, dispose: () => undefined }; } for (const signal of signals) signal.addEventListener('abort', onAbort, { once: true }); return { signal: controller.signal, dispose }; } /** Keep the timeout active until the complete response body has been read. */ export async function requestText(config, url, init = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.timeoutMs); const { signal, dispose } = init.signal ? anySignal([init.signal, controller.signal]) : { signal: controller.signal, dispose: () => undefined }; try { // Never forward token-bearing POST bodies (or credentials) through redirects. // 'manual' is honoured because it surfaces the 3xx without following it. const response = await fetch(url, { ...init, signal, redirect: init.redirect === 'manual' ? 'manual' : 'error' }); return { response, text: await readBoundedBody(response, signal) }; } catch (error) { if (error instanceof NetworkError) throw error; if (init.signal?.aborted) { throw new NetworkError('Request cancelled.', 'CANCELLED', error); } throw networkError(error, url, config.timeoutMs, controller.signal.aborted); } finally { clearTimeout(timer); dispose(); } } export function parseJsonResponse(response, text) { try { // Empty successful responses are valid for some mutation endpoints. return text ? JSON.parse(text) : null; } catch { if (!response.ok) return null; throw new NetworkError(`Expected JSON but received a non-JSON response (HTTP ${response.status}). Check the configured API URL and any proxy or sign-in page.`, 'INVALID_RESPONSE'); } }