fix(design): bound requests and prevent mutation replay
This commit is contained in:
@@ -6,6 +6,77 @@
|
||||
|
||||
import { net } from 'electron';
|
||||
|
||||
const SAFE_FALLBACK_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export class RequestDeadlineExceededError extends Error {
|
||||
readonly timeoutMs: number;
|
||||
|
||||
constructor(timeoutMs: number) {
|
||||
super(`Request did not complete within ${timeoutMs}ms`);
|
||||
this.name = 'RequestDeadlineExceededError';
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
function abortReason(signal: AbortSignal): unknown {
|
||||
if (signal.reason !== undefined) return signal.reason;
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function runWithDeadline<T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
sourceSignal?: AbortSignal | null,
|
||||
): Promise<T> {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new RangeError('Request timeout must be a positive finite number');
|
||||
}
|
||||
|
||||
if (sourceSignal?.aborted) throw abortReason(sourceSignal);
|
||||
|
||||
const controller = new AbortController();
|
||||
let rejectInterruption!: (reason: unknown) => void;
|
||||
const interruption = new Promise<never>((_resolve, reject) => {
|
||||
rejectInterruption = reject;
|
||||
});
|
||||
const abortFromSource = () => {
|
||||
if (!sourceSignal) return;
|
||||
const reason = abortReason(sourceSignal);
|
||||
controller.abort(reason);
|
||||
rejectInterruption(reason);
|
||||
};
|
||||
sourceSignal?.addEventListener('abort', abortFromSource, { once: true });
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const error = new RequestDeadlineExceededError(timeoutMs);
|
||||
controller.abort(error);
|
||||
rejectInterruption(error);
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
try {
|
||||
return await Promise.race([operation(controller.signal), interruption]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
sourceSignal?.removeEventListener('abort', abortFromSource);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchWithDeadline(
|
||||
fetchImpl: typeof fetch,
|
||||
input: Parameters<typeof fetch>[0],
|
||||
init: RequestInit | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
return runWithDeadline(
|
||||
(signal) => fetchImpl(input, { ...init, signal }),
|
||||
timeoutMs,
|
||||
init?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
export async function proxyAwareFetch(
|
||||
input: string | URL,
|
||||
init?: RequestInit
|
||||
@@ -13,8 +84,12 @@ export async function proxyAwareFetch(
|
||||
if (process.versions.electron) {
|
||||
try {
|
||||
return await net.fetch(input, init);
|
||||
} catch {
|
||||
// Fall through to the global fetch.
|
||||
} catch (error) {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
if (!SAFE_FALLBACK_METHODS.has(method) || init?.signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
// Safe reads retain the Node fallback for proxy compatibility.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user