Transport policy enforcer

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

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/591898/1906160/Transport%20policy%20enforcer.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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));