Transport policy enforcer

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

Tätä skriptiä ei tulisi asentaa suoraan. Se on kirjasto muita skriptejä varten sisällytettäväksi metadirektiivillä // @require https://update.greasyfork.org/scripts/591898/1906160/Transport%20policy%20enforcer.js.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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));