Transport policy enforcer

Enforce request policies for fetch() or GM_xmlhttpRequest methods with fallback routing

Este script não deve ser instalado diretamente. É uma biblioteca destinada a ser incluída por outros scripts através da diretiva de metadados // @require https://update.greasyfork.org/scripts/591898/1906160/Transport%20policy%20enforcer.js

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name           Transport policy enforcer
// @namespace      861ddd094884eac5bea7a3b12e074f34
// @author         Anonymous, Codex
// @version        1.0.0
// @license        BSD-0
// @grant          none
// ==/UserScript==

(function (global) {
    'use strict';

    const TRANSPORTS = ['gm-first', 'fetch-first', 'gm-only', 'fetch-only'];
    const config = {
        transport: 'gm-first',
        fallbackOn: {
            transportError: true,
            statuses: [403, 429, [500, 599]],
            predicate: null,
        },
        gmProvider: () =>
            (typeof GM !== 'undefined' && GM.xmlHttpRequest) ||
            (typeof GM_xmlhttpRequest === 'function' && GM_xmlhttpRequest) ||
            null,
        fetchProvider: () =>
            (typeof window !== 'undefined' && window.fetch)
                ? window.fetch.bind(window)
                : (typeof fetch === 'function' ? fetch : null),
        timeoutMs: 30000,
        onTransportEvent: null,
    };

    function configure(options) {
        return Object.assign(config, options || {});
    }

    function matchesStatus(status, list) {
        if (!Array.isArray(list)) return false;
        const value = Number(status);
        return list.some((entry) => {
            if (Array.isArray(entry)) {
                return entry.length === 2 && value >= Number(entry[0]) && value <= Number(entry[1]);
            }
            return value === Number(entry);
        });
    }

    function emit(event) {
        if (typeof config.onTransportEvent !== 'function') return;
        try { config.onTransportEvent(event); } catch (e) { /* host hook is best-effort */ }
    }

    function asError(value, message) {
        if (value instanceof Error) return value;
        const error = new Error(value && value.message ? value.message : message);
        if (value !== undefined) error.cause = value;
        return error;
    }

    function transportError(value, transport, details, kind) {
        const error = asError(value, `TransportPolicy: ${kind} using ${transport} for ${details.url}`);
        try { error.transport = transport; } catch (e) { /* a frozen provider error is replaced below */ }
        if (error.transport !== transport) {
            const replacement = new Error(error.message);
            replacement.cause = error;
            replacement.transport = transport;
            if (kind === 'timeout') replacement.timedOut = true;
            return replacement;
        }
        if (kind === 'timeout') error.timedOut = true;
        return error;
    }

    function normaliseGmResponse(raw, details) {
        raw = raw || {};
        const responseText = typeof raw.responseText === 'string' ? raw.responseText : '';
        return {
            status: Number(raw.status) || 0,
            statusText: raw.statusText || '',
            responseText,
            response: raw.response !== undefined ? raw.response : responseText,
            responseHeaders: raw.responseHeaders || '',
            finalUrl: raw.finalUrl || details.url,
            transport: 'gm',
            fellBack: false,
        };
    }

    function gmRequest(details, provider) {
        return new Promise((resolve, reject) => {
            const options = Object.assign({}, details, {
                timeout: details.timeout ?? config.timeoutMs,
                onload: (response) => resolve(normaliseGmResponse(response, details)),
                onerror: (error) => reject(transportError(error, 'gm', details, 'network error')),
                ontimeout: (error) => reject(transportError(error, 'gm', details, 'timeout')),
                onabort: (error) => reject(transportError(error, 'gm', details, 'abort')),
            });
            try {
                provider(options);
            } catch (error) {
                reject(transportError(error, 'gm', details, 'provider error'));
            }
        });
    }

    function headerString(headers) {
        let lines = '';
        if (headers && typeof headers.forEach === 'function') {
            headers.forEach((value, name) => { lines += `${name}: ${value}\r\n`; });
        }
        return lines;
    }

    async function fetchRequest(details, provider) {
        const timeout = details.timeout ?? config.timeoutMs;
        const init = {
            method: details.method || 'GET',
            credentials: 'include',
            cache: 'no-store',
            redirect: 'follow',
        };
        if (details.headers) init.headers = Object.assign({}, details.headers);
        if (details.data != null) init.body = details.data;

        let controller;
        let timer;
        if (timeout > 0 && typeof AbortController === 'function') {
            controller = new AbortController();
            init.signal = controller.signal;
            timer = setTimeout(() => controller.abort(), timeout);
        }

        try {
            const raw = await provider(details.url, init);
            const responseType = details.responseType || 'text';
            let response;
            let responseText = '';
            if (responseType === 'json') {
                responseText = await raw.text();
                response = JSON.parse(responseText);
            } else if (responseType === 'arraybuffer') {
                response = await raw.arrayBuffer();
            } else if (responseType === 'blob') {
                response = await raw.blob();
            } else {
                responseText = await raw.text();
                response = responseText;
            }
            return {
                status: Number(raw.status) || 0,
                statusText: raw.statusText || '',
                responseText,
                response,
                responseHeaders: headerString(raw.headers),
                finalUrl: raw.url || details.url,
                transport: 'fetch',
                fellBack: false,
            };
        } catch (error) {
            const timedOut = !!(controller && controller.signal.aborted);
            throw transportError(error, 'fetch', details, timedOut ? 'timeout' : 'transport error');
        } finally {
            if (timer !== undefined) clearTimeout(timer);
        }
    }

    function providerFor(transport) {
        const factory = transport === 'gm' ? config.gmProvider : config.fetchProvider;
        if (typeof factory !== 'function') return null;
        return factory();
    }

    async function attempt(transport, details) {
        emit({ type: 'attempt', transport, url: details.url, method: details.method || 'GET' });
        let provider;
        try {
            provider = providerFor(transport);
        } catch (error) {
            throw transportError(error, transport, details, 'provider error');
        }
        if (typeof provider !== 'function') {
            const error = transportError(undefined, transport, details, 'unavailable');
            error.unavailable = true;
            throw error;
        }
        return transport === 'gm'
            ? gmRequest(details, provider)
            : fetchRequest(details, provider);
    }

    function ordering(policy) {
        switch (policy) {
        case 'gm-first': return ['gm', 'fetch'];
        case 'fetch-first': return ['fetch', 'gm'];
        case 'gm-only': return ['gm'];
        case 'fetch-only': return ['fetch'];
        default: throw new Error(`TransportPolicy: unknown transport policy ${policy}`);
        }
    }

    function fallbackDecision(outcome, rules, transport) {
        if (typeof rules.predicate === 'function') {
            const verdict = rules.predicate(outcome.response, transport);
            if (typeof verdict === 'boolean') {
                return verdict ? { fallback: true, reason: 'predicate' } : { fallback: false };
            }
        }
        if (outcome.error) {
            return rules.transportError
                ? { fallback: true, reason: 'transport-error' }
                : { fallback: false };
        }
        return matchesStatus(outcome.response.status, rules.statuses)
            ? { fallback: true, reason: 'status' }
            : { fallback: false };
    }

    function primarySummary(transport, outcome) {
        if (outcome.error) return { transport, error: outcome.error };
        return { transport, status: outcome.response.status };
    }

    function finalEvent(type, transport, details, value, reason) {
        const event = { type, transport, url: details.url, method: details.method || 'GET' };
        if (value && typeof value.status === 'number') event.status = value.status;
        if (value instanceof Error) event.error = value;
        if (reason) event.reason = reason;
        emit(event);
    }

    async function request(input) {
        const details = input || {};
        const policy = details.transport || config.transport;
        const order = ordering(policy);
        const rules = Object.assign({}, config.fallbackOn || {}, details.fallbackOn || {});
        const { transport: ignoredTransport, fallbackOn: ignoredFallback, ...networkDetails } = details;
        const primaryTransport = order[0];
        let primary;

        try {
            primary = { response: await attempt(primaryTransport, networkDetails) };
        } catch (error) {
            primary = { error };
        }

        if (order.length === 1) {
            if (primary.error) {
                finalEvent('rejected', primaryTransport, networkDetails, primary.error,
                    primary.error.unavailable ? 'unavailable' : 'transport-error');
                throw primary.error;
            }
            finalEvent('resolved', primaryTransport, networkDetails, primary.response);
            return primary.response;
        }

        let decision;
        if (primary.error && primary.error.unavailable) {
            decision = { fallback: true, reason: 'unavailable' };
        } else {
            try {
                decision = fallbackDecision(primary, rules, primaryTransport);
            } catch (error) {
                const rejection = transportError(error, primaryTransport, networkDetails, 'predicate error');
                finalEvent('rejected', primaryTransport, networkDetails, rejection);
                throw rejection;
            }
        }

        if (!decision.fallback) {
            if (primary.error) {
                finalEvent('rejected', primaryTransport, networkDetails, primary.error, 'transport-error');
                throw primary.error;
            }
            finalEvent('resolved', primaryTransport, networkDetails, primary.response);
            return primary.response;
        }

        const fallbackEvent = {
            type: 'fallback',
            transport: primaryTransport,
            url: networkDetails.url,
            method: networkDetails.method || 'GET',
            reason: decision.reason,
        };
        if (primary.response) fallbackEvent.status = primary.response.status;
        if (primary.error) fallbackEvent.error = primary.error;
        emit(fallbackEvent);

        const secondaryTransport = order[1];
        try {
            const response = await attempt(secondaryTransport, networkDetails);
            response.fellBack = true;
            response.primary = primarySummary(primaryTransport, primary);
            finalEvent('resolved', secondaryTransport, networkDetails, response);
            return response;
        } catch (error) {
            error.transport = secondaryTransport;
            if (primary.error) error.primaryError = primary.error;
            else error.primaryResponse = primary.response;
            finalEvent('rejected', secondaryTransport, networkDetails, error,
                error.unavailable ? 'unavailable' : 'transport-error');
            throw error;
        }
    }

    function xmlHttpRequest(details) {
        const { onload, onerror, ontimeout, ...requestDetails } = details;
        request(requestDetails).then(
            (response) => { if (typeof onload === 'function') onload(response); },
            (error) => {
                if (error.timedOut && typeof ontimeout === 'function') ontimeout(error);
                else if (typeof onerror === 'function') onerror(error);
            }
        );
        return { abort() {} };
    }

    global.TransportPolicy = {
        configure,
        config,
        request,
        xmlHttpRequest,
        TRANSPORTS,
        matchesStatus,
    };
}(typeof globalThis !== 'undefined' ? globalThis : this));