P-Stream Userscript

A P-Stream compatible userscript

2026-04-10 या दिनांकाला. सर्वात नवीन आवृत्ती पाहा.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         P-Stream Userscript
// @namespace    https://pstream.net/
// @version      1.5.0
// @description  A P-Stream compatible userscript
// @author       groknt
// @license      MIT
// @match        *://pstream.net/*
// @match        *://aether.mom/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-start
// @connect      *
// ==/UserScript==

(function () {
  "use strict";

  const VERSION = "1.5.0";
  const LOG_PREFIX = "P-Stream:";

  const CORS_HEADERS = Object.freeze({
    "access-control-allow-origin": "*",
    "access-control-allow-methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
    "access-control-allow-headers": "*",
  });

  const MODIFIABLE_HEADERS = new Set([
    "access-control-allow-origin",
    "access-control-allow-methods",
    "access-control-allow-headers",
    "content-security-policy",
    "content-security-policy-report-only",
    "content-disposition",
  ]);

  const STREAMING_EXTENSIONS_RE = /\.(m3u8|mpd)(?:\?|$)/i;
  const STREAMING_MIME_TYPES = ["mpegurl", "dash+xml"];

  const XHR_STATES = Object.freeze({
    UNSENT: 0,
    OPENED: 1,
    HEADERS_RECEIVED: 2,
    LOADING: 3,
    DONE: 4,
  });

  const XHR_EVENT_TYPES = [
    "readystatechange",
    "load",
    "error",
    "timeout",
    "abort",
    "loadend",
    "progress",
    "loadstart",
  ];

  const PROGRESS_EVENT_TYPES = new Set([
    "load",
    "error",
    "timeout",
    "abort",
    "loadend",
    "progress",
    "loadstart",
  ]);

  const globalContext =
    typeof unsafeWindow !== "undefined" ? unsafeWindow : window;

  const gmXmlHttpRequest =
    typeof GM_xmlhttpRequest === "function"
      ? GM_xmlhttpRequest
      : typeof GM?.xmlHttpRequest === "function"
        ? GM.xmlHttpRequest
        : null;

  if (!gmXmlHttpRequest) {
    console.warn(
      LOG_PREFIX,
      "GM_xmlhttpRequest unavailable — proxy requests will fail",
    );
  }

  const pageOrigin = (() => {
    try {
      const { origin, href } = globalContext.location;
      return origin !== "null" ? origin : new URL(href).origin;
    } catch {
      return "*";
    }
  })();

  const proxyRules = new Map();
  const blobUrlRegistry = new Set();
  const proxyCache = new Map();
  const regexCache = new Map();
  const patchStatus = { fetch: false, xhr: false, media: false };

  const textDecoder = new TextDecoder();
  const textEncoder = new TextEncoder();

  function normalizeUrl(input, base) {
    if (!input) return null;
    try {
      return new URL(input, base || globalContext.location.href).href;
    } catch {
      return null;
    }
  }

  function parseUrl(input) {
    if (!input) return null;
    try {
      return new URL(input, globalContext.location.href);
    } catch {
      return null;
    }
  }

  function isSameOrigin(url) {
    try {
      return new URL(url).origin === pageOrigin;
    } catch {
      return false;
    }
  }

  function buildUrl(url, options = {}) {
    const { baseUrl, query } = options;

    let fullUrl;
    if (/^https?:\/\//i.test(url)) {
      fullUrl = url;
    } else if (baseUrl) {
      const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
      const path = url.startsWith("/") ? url.slice(1) : url;
      fullUrl = `${base}${path}`;
    } else {
      fullUrl = url;
    }

    if (!/^https?:\/\//i.test(fullUrl)) {
      throw new Error(`Invalid URL scheme: ${fullUrl}`);
    }

    if (!query || Object.keys(query).length === 0) return fullUrl;

    const parsed = new URL(fullUrl);
    for (const key in query) {
      parsed.searchParams.set(key, query[key]);
    }
    return parsed.href;
  }

  function parseResponseHeaders(raw) {
    const headers = Object.create(null);
    if (!raw) return headers;

    const lines = raw.split("\n");
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      const idx = line.indexOf(":");
      if (idx === -1) continue;

      const key = line.slice(0, idx).trim().toLowerCase();
      if (!key) continue;

      const value = line.slice(idx + 1).trim();
      const existing = headers[key];
      headers[key] = existing ? `${existing}, ${value}` : value;
    }
    return headers;
  }

  function buildResponseHeaders(raw, ruleHeaders, includeCredentials) {
    const parsed = parseResponseHeaders(raw);

    for (const key of MODIFIABLE_HEADERS) {
      delete parsed[key];
    }

    if (ruleHeaders) {
      for (const key in ruleHeaders) {
        parsed[key] = ruleHeaders[key];
      }
    }

    parsed["access-control-allow-origin"] =
      includeCredentials ? pageOrigin : "*";
    parsed["access-control-allow-methods"] =
      "GET, POST, PUT, DELETE, PATCH, OPTIONS";
    parsed["access-control-allow-headers"] = "*";

    if (includeCredentials) {
      parsed["access-control-allow-credentials"] = "true";
    }

    return parsed;
  }

  function shouldIncludeCredentials(url, credentialsMode, force) {
    if (force || credentialsMode === "include") return true;
    if (credentialsMode === "omit") return false;
    return isSameOrigin(url);
  }

  function normalizeRequestBody(body) {
    if (body == null) return undefined;
    if (
      typeof body === "string" ||
      body instanceof FormData ||
      body instanceof Blob ||
      body instanceof ArrayBuffer ||
      ArrayBuffer.isView(body)
    ) {
      return body;
    }
    if (body instanceof URLSearchParams) return body.toString();
    if (typeof body === "object") return JSON.stringify(body);
    return body;
  }

  function deserializeRequestBody(body, bodyType) {
    if (body == null) return undefined;
    switch (bodyType) {
      case "FormData": {
        const fd = new FormData();
        for (const [key, value] of body) {
          fd.append(key, value);
        }
        return fd;
      }
      case "URLSearchParams":
        return new URLSearchParams(body);
      case "object":
        return JSON.stringify(body);
      default:
        return body;
    }
  }

  function executeGmRequest(options) {
    return new Promise((resolve, reject) => {
      if (!gmXmlHttpRequest) {
        reject(new Error("GM_xmlhttpRequest unavailable"));
        return;
      }

      gmXmlHttpRequest({
        ...options,
        onload: resolve,
        onerror: (err) =>
          reject(new Error(err?.error || err?.message || "Network error")),
        ontimeout: () => reject(new Error("Request timeout")),
      });
    });
  }

  function responseToArrayBuffer(response) {
    if (response.response instanceof ArrayBuffer) {
      return response.response;
    }
    const encoded = textEncoder.encode(response.responseText || "");
    return encoded.buffer.byteLength === encoded.byteLength
      ? encoded.buffer
      : encoded.buffer.slice(
          encoded.byteOffset,
          encoded.byteOffset + encoded.byteLength,
        );
  }

  function getCompiledRegex(pattern) {
    let cached = regexCache.get(pattern);
    if (cached !== undefined) return cached;
    try {
      cached = new RegExp(pattern);
    } catch {
      cached = null;
    }
    regexCache.set(pattern, cached);
    return cached;
  }

  function findMatchingRule(url) {
    const parsed = parseUrl(url);
    if (!parsed) return null;

    const { href, hostname } = parsed;

    for (const rule of proxyRules.values()) {
      const domains = rule.targetDomains;
      if (domains && domains.length > 0) {
        for (let i = 0; i < domains.length; i++) {
          const d = domains[i];
          if (hostname === d || hostname.endsWith(`.${d}`)) return rule;
        }
      }

      if (rule.targetRegex) {
        const regex = getCompiledRegex(rule.targetRegex);
        if (regex && regex.test(href)) return rule;
      }
    }

    return null;
  }

  function isStreamingContent(contentType, url) {
    if (STREAMING_EXTENSIONS_RE.test(url)) return true;
    for (let i = 0; i < STREAMING_MIME_TYPES.length; i++) {
      if (contentType.includes(STREAMING_MIME_TYPES[i])) return true;
    }
    return false;
  }

  function createBlobUrl(data, contentType) {
    const url = URL.createObjectURL(
      new Blob([data], { type: contentType || "application/octet-stream" }),
    );
    blobUrlRegistry.add(url);
    return url;
  }

  function cleanupStreamData() {
    for (const url of blobUrlRegistry) {
      try {
        URL.revokeObjectURL(url);
      } catch {}
    }
    blobUrlRegistry.clear();
    proxyCache.clear();
  }

  function createXhrEvent(type, loaded, total) {
    if (PROGRESS_EVENT_TYPES.has(type)) {
      return new ProgressEvent(type, {
        lengthComputable: total > 0,
        loaded: loaded || 0,
        total: total || 0,
      });
    }
    return new Event(type);
  }

  function proxyMediaSource(url) {
    const normalized = normalizeUrl(url);
    if (!normalized) return Promise.resolve(null);

    const rule = findMatchingRule(normalized);
    if (!rule) return Promise.resolve(null);

    const cached = proxyCache.get(normalized);
    if (cached) return cached;

    const promise = (async () => {
      try {
        const response = await executeGmRequest({
          url: normalized,
          method: "GET",
          headers: rule.requestHeaders,
          responseType: "arraybuffer",
          withCredentials: true,
        });

        const contentType =
          parseResponseHeaders(response.responseHeaders)["content-type"] || "";
        if (isStreamingContent(contentType, normalized)) return null;

        return createBlobUrl(responseToArrayBuffer(response), contentType);
      } catch (err) {
        console.warn(LOG_PREFIX, "Media proxy failed:", err.message);
        return null;
      } finally {
        setTimeout(() => proxyCache.delete(normalized), 1000);
      }
    })();

    proxyCache.set(normalized, promise);
    return promise;
  }

  function patchFetch() {
    if (patchStatus.fetch) return;
    patchStatus.fetch = true;

    const nativeFetch = globalContext.fetch.bind(globalContext);

    globalContext.fetch = async function (input, init = {}) {
      const url = normalizeUrl(typeof input === "string" ? input : input?.url);
      const rule = url && findMatchingRule(url);
      if (!rule) return nativeFetch(input, init);

      const headers = {
        ...rule.requestHeaders,
        ...(init.headers instanceof Headers
          ? Object.fromEntries(init.headers)
          : init.headers),
      };
      const includeCreds = shouldIncludeCredentials(url, init.credentials);

      try {
        const response = await executeGmRequest({
          url,
          method: init.method || "GET",
          headers,
          data: normalizeRequestBody(init.body),
          responseType: "arraybuffer",
          withCredentials: includeCreds,
        });

        return new Response(responseToArrayBuffer(response), {
          status: response.status,
          statusText: response.statusText || "",
          headers: buildResponseHeaders(
            response.responseHeaders,
            rule.responseHeaders,
            includeCreds,
          ),
        });
      } catch (err) {
        console.warn(LOG_PREFIX, "Fetch proxy failed:", err.message);
        return nativeFetch(input, init);
      }
    };
  }

  function patchXhr() {
    if (patchStatus.xhr) return;
    patchStatus.xhr = true;

    const NativeXHR = globalContext.XMLHttpRequest;

    class ProxyXMLHttpRequest {
      constructor() {
        this._native = new NativeXHR();
        this._useNative = true;
        this._listeners = new Map();
        this._reqHeaders = {};
        this._resHeaders = null;
        this._rule = null;
        this._url = "";
        this._method = "GET";
        this._aborted = false;
        this._timeoutId = null;
        this._mimeOverride = "";
        this._nativeBound = false;

        this.readyState = 0;
        this.status = 0;
        this.statusText = "";
        this.response = null;
        this.responseText = "";
        this.responseURL = "";
        this.responseType = "";
        this.withCredentials = false;
        this.timeout = 0;
        this.upload = this._native.upload;

        this.onreadystatechange = null;
        this.onload = null;
        this.onerror = null;
        this.ontimeout = null;
        this.onabort = null;
        this.onloadend = null;
        this.onprogress = null;
        this.onloadstart = null;
      }

      _emit(type, event) {
        if (!event) event = createXhrEvent(type);

        const handler = this[`on${type}`];
        if (handler) {
          try {
            handler.call(this, event);
          } catch (e) {
            console.error(LOG_PREFIX, "XHR handler error:", e);
          }
        }

        const list = this._listeners.get(type);
        if (list && list.length > 0) {
          const snapshot = list.slice();
          for (let i = 0; i < snapshot.length; i++) {
            try {
              snapshot[i].call(this, event);
            } catch (e) {
              console.error(LOG_PREFIX, "XHR listener error:", e);
            }
          }
        }
      }

      _syncNative() {
        if (!this._useNative) return;
        try {
          this.readyState = this._native.readyState;
          if (this.readyState >= 2) {
            this.status = this._native.status;
            this.statusText = this._native.statusText;
          }
          if (this.readyState >= 3) {
            this.response = this._native.response;
          }
          if (this.readyState === 4) {
            this.responseURL = this._native.responseURL;
            const rt = this._native.responseType;
            if (!rt || rt === "text") {
              this.responseText = this._native.responseText;
            }
          }
        } catch {}
      }

      _bindNative() {
        if (this._nativeBound) return;
        this._nativeBound = true;

        const self = this;
        for (let i = 0; i < XHR_EVENT_TYPES.length; i++) {
          const type = XHR_EVENT_TYPES[i];
          this._native.addEventListener(type, function (event) {
            self._syncNative();
            self._emit(type, event);
          });
        }
      }

      _applyResponse(buffer) {
        const contentType =
          this.getResponseHeader("content-type") ||
          this._mimeOverride ||
          "application/octet-stream";

        switch (this.responseType) {
          case "arraybuffer":
            this.response = buffer;
            break;

          case "blob":
            this.response = new Blob([buffer], { type: contentType });
            break;

          case "json": {
            const text = textDecoder.decode(buffer);
            this.responseText = text;
            try {
              this.response = JSON.parse(text);
            } catch {
              this.response = null;
            }
            break;
          }

          case "document": {
            const text = textDecoder.decode(buffer);
            this.responseText = text;
            try {
              const mime = contentType.includes("xml")
                ? "application/xml"
                : "text/html";
              this.response = new DOMParser().parseFromString(text, mime);
            } catch {
              this.response = null;
            }
            break;
          }

          default: {
            const text = textDecoder.decode(buffer);
            this.response = text;
            this.responseText = text;
          }
        }
      }

      addEventListener(type, callback) {
        let list = this._listeners.get(type);
        if (!list) {
          list = [];
          this._listeners.set(type, []);
        }
        this._listeners.get(type).push(callback);
      }

      removeEventListener(type, callback) {
        const list = this._listeners.get(type);
        if (!list) return;
        const idx = list.indexOf(callback);
        if (idx !== -1) list.splice(idx, 1);
      }

      dispatchEvent(event) {
        if (this._useNative && this._nativeBound) {
          return this._native.dispatchEvent(event);
        }
        this._emit(event.type, event);
        return !event.defaultPrevented;
      }

      open(method, url, async, username, password) {
        this._method = method;
        this._url = normalizeUrl(url) || url;
        this._rule = findMatchingRule(this._url);
        this._useNative = !this._rule;
        this._aborted = false;
        this._reqHeaders = {};

        if (this._useNative) {
          this._native.open(
            method,
            url,
            async !== undefined ? async : true,
            username,
            password,
          );
          this.readyState = this._native.readyState;
          return;
        }

        this.readyState = 1;
        this._emit("readystatechange");
      }

      setRequestHeader(name, value) {
        if (this._useNative) {
          return this._native.setRequestHeader(name, value);
        }
        this._reqHeaders[name] = value;
      }

      getResponseHeader(name) {
        if (this._useNative) {
          return this._native.getResponseHeader(name);
        }
        if (!this._resHeaders) return null;
        return this._resHeaders[name?.toLowerCase()] ?? null;
      }

      getAllResponseHeaders() {
        if (this._useNative) {
          return this._native.getAllResponseHeaders();
        }
        if (!this._resHeaders) return "";
        const h = this._resHeaders;
        const keys = Object.keys(h);
        const parts = new Array(keys.length);
        for (let i = 0; i < keys.length; i++) {
          parts[i] = `${keys[i]}: ${h[keys[i]]}`;
        }
        return parts.join("\r\n");
      }

      overrideMimeType(mime) {
        if (this._useNative) {
          return this._native.overrideMimeType(mime);
        }
        this._mimeOverride = mime;
      }

      abort() {
        if (this._useNative) {
          return this._native.abort();
        }

        if (this._timeoutId) {
          clearTimeout(this._timeoutId);
          this._timeoutId = null;
        }

        this._aborted = true;
        this.readyState = 0;
        this._emit("abort");
      }

      async send(body) {
        if (this._useNative) {
          this._native.withCredentials = this.withCredentials;
          this._native.responseType = this.responseType;
          this._native.timeout = this.timeout;
          this._bindNative();
          return this._native.send(body === undefined ? null : body);
        }

        const { _rule: rule, _url: url, _method: method } = this;
        const headers = { ...rule.requestHeaders, ...this._reqHeaders };
        const includeCreds = shouldIncludeCredentials(
          url,
          this.withCredentials ? "include" : undefined,
          this.withCredentials,
        );
        const wantBinary =
          this.responseType === "arraybuffer" || this.responseType === "blob";

        const reqPromise = executeGmRequest({
          url,
          method,
          headers,
          data: normalizeRequestBody(body === undefined ? null : body),
          responseType: wantBinary ? "arraybuffer" : "text",
          withCredentials: includeCreds,
        });

        let timeoutPromise;
        if (this.timeout > 0) {
          timeoutPromise = new Promise((_, reject) => {
            this._timeoutId = setTimeout(
              () => reject(new Error("timeout")),
              this.timeout,
            );
          });
        }

        this._emit("loadstart");

        try {
          const response = await (timeoutPromise
            ? Promise.race([reqPromise, timeoutPromise])
            : reqPromise);

          if (this._timeoutId) {
            clearTimeout(this._timeoutId);
            this._timeoutId = null;
          }
          if (this._aborted) return;

          this._resHeaders = buildResponseHeaders(
            response.responseHeaders,
            rule.responseHeaders,
            includeCreds,
          );
          this.responseURL = response.finalUrl || url;
          this.status = response.status;
          this.statusText = response.statusText || "";

          const buffer = responseToArrayBuffer(response);
          const size = buffer.byteLength;

          this.readyState = 2;
          this._emit("readystatechange");

          this.readyState = 3;
          this._emit("readystatechange");
          this._emit("progress", createXhrEvent("progress", size, size));

          this._applyResponse(buffer);

          this.readyState = 4;
          this._emit("readystatechange");
          this._emit("load", createXhrEvent("load", size, size));
          this._emit("loadend", createXhrEvent("loadend", size, size));
        } catch (err) {
          if (this._timeoutId) {
            clearTimeout(this._timeoutId);
            this._timeoutId = null;
          }
          if (this._aborted) return;

          this.status = 0;
          this.statusText = err.message || "";
          this.readyState = 4;

          const type = err.message === "timeout" ? "timeout" : "error";
          this._emit("readystatechange");
          this._emit(type);
          this._emit("loadend");
        }
      }
    }

    Object.assign(ProxyXMLHttpRequest, XHR_STATES);
    Object.assign(ProxyXMLHttpRequest.prototype, XHR_STATES);
    globalContext.XMLHttpRequest = ProxyXMLHttpRequest;
  }

  function patchMediaElements() {
    if (patchStatus.media) return;
    patchStatus.media = true;

    const proto = globalContext.HTMLMediaElement.prototype;
    const srcDesc = Object.getOwnPropertyDescriptor(proto, "src");
    const nativeSetAttr = proto.setAttribute;

    if (srcDesc?.set) {
      const originalSet = srcDesc.set;

      Object.defineProperty(proto, "src", {
        ...srcDesc,
        set(value) {
          if (typeof value !== "string") {
            originalSet.call(this, value);
            return;
          }

          const normalized = normalizeUrl(value);
          if (!normalized) {
            originalSet.call(this, value);
            return;
          }

          originalSet.call(this, value);
          const el = this;

          proxyMediaSource(value)
            .then((proxied) => {
              if (proxied && el.src === normalized) {
                originalSet.call(el, proxied);
              }
            })
            .catch(() => {});
        },
      });
    }

    proto.setAttribute = function (name, value) {
      if (name?.toLowerCase() !== "src" || typeof value !== "string") {
        return nativeSetAttr.call(this, name, value);
      }

      const normalized = normalizeUrl(value);
      if (!normalized) {
        return nativeSetAttr.call(this, "src", value);
      }

      nativeSetAttr.call(this, "src", value);
      const el = this;

      proxyMediaSource(value)
        .then((proxied) => {
          if (proxied && el.src === normalized) {
            nativeSetAttr.call(el, "src", proxied);
          }
        })
        .catch(() => {});
    };

    globalContext.addEventListener("beforeunload", cleanupStreamData);
  }

  function installProxies() {
    patchFetch();
    patchXhr();
    patchMediaElements();
  }

  const messageHandlers = {
    hello() {
      return {
        success: true,
        version: VERSION,
        allowed: true,
        hasPermission: true,
      };
    },

    async makeRequest(body) {
      if (!body) throw new Error("Missing request body");

      const url = buildUrl(body.url, body);
      const includeCreds = shouldIncludeCredentials(
        url,
        body.credentials,
        body.withCredentials,
      );

      const response = await executeGmRequest({
        url,
        method: body.method || "GET",
        headers: body.headers,
        data: deserializeRequestBody(body.body, body.bodyType),
        responseType: "arraybuffer",
        withCredentials: includeCreds,
      });

      const headers = buildResponseHeaders(
        response.responseHeaders,
        null,
        includeCreds,
      );
      const text = textDecoder.decode(responseToArrayBuffer(response));
      const contentType = headers["content-type"] || "";

      let parsedBody = text;
      if (contentType.includes("application/json")) {
        try {
          parsedBody = JSON.parse(text);
        } catch {}
      }

      return {
        success: true,
        response: {
          statusCode: response.status,
          headers,
          finalUrl: response.finalUrl || url,
          body: parsedBody,
        },
      };
    },

    async prepareStream(body) {
      if (!body) throw new Error("Missing request body");

      cleanupStreamData();

      const existing = proxyRules.get(body.ruleId);
      if (existing?.targetRegex) {
        regexCache.delete(existing.targetRegex);
      }

      const responseHeaders = {};
      if (body.responseHeaders) {
        const src = body.responseHeaders;
        const keys = Object.keys(src);
        for (let i = 0; i < keys.length; i++) {
          const lower = keys[i].toLowerCase();
          if (MODIFIABLE_HEADERS.has(lower)) {
            responseHeaders[lower] = src[keys[i]];
          }
        }
      }

      proxyRules.set(body.ruleId, { ...body, responseHeaders });
      installProxies();

      return { success: true };
    },

    openPage(body) {
      if (body?.redirectUrl) {
        globalContext.location.href = body.redirectUrl;
      }
      return { success: true };
    },
  };

  function setupMessageRelay(name, handler) {
    globalContext.addEventListener("message", async (event) => {
      const data = event.data;
      if (
        event.source !== globalContext ||
        data?.name !== name ||
        data?.relayed
      ) {
        return;
      }

      const { instanceId, body } = data;

      try {
        const result = await handler(body);
        globalContext.postMessage(
          { name, instanceId, body: result, relayed: true },
          "/",
        );
      } catch (err) {
        console.error(LOG_PREFIX, `${name} handler failed:`, err.message);
        globalContext.postMessage(
          {
            name,
            instanceId,
            body: { success: false, error: err.message || String(err) },
            relayed: true,
          },
          "/",
        );
      }
    });
  }

  const handlerNames = Object.keys(messageHandlers);
  for (let i = 0; i < handlerNames.length; i++) {
    const name = handlerNames[i];
    setupMessageRelay(name, messageHandlers[name]);
  }
})();