P-Stream (GrokNT Fork)

P-Stream compatible UserScript

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         P-Stream (GrokNT Fork)
// @namespace    https://pstream.mov/
// @version      1.4.3
// @description  P-Stream compatible UserScript
// @author       Duplicake, P-Stream Team, groknt
// @icon         https://raw.githubusercontent.com/p-stream/p-stream/production/public/mstile-150x150.jpeg
// @match        *://pstream.mov/*
// @match        *://aether.mom/*
// @match        *://lordflix.club/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-start
// @connect      *
// ==/UserScript==

(function () {
  "use strict";

  const VERSION = "1.4.3";
  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_REGEX = /\.(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 = Object.freeze([
    "readystatechange",
    "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;

  const pageOrigin = (function () {
    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 };

  function logWarning(...args) {
    console.warn(LOG_PREFIX, ...args);
  }

  function logError(...args) {
    console.error(LOG_PREFIX, ...args);
  }

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

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

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

  function buildUrl(url, options = {}) {
    const { baseUrl = "", query = {} } = options;
    const base = baseUrl.endsWith("/") ? baseUrl : baseUrl && `${baseUrl}/`;
    const path = url.startsWith("/") ? url.slice(1) : url;
    const fullUrl = `${base}${path}`;

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

    const parsedUrl = new URL(fullUrl);
    for (const [key, value] of Object.entries(query)) {
      parsedUrl.searchParams.set(key, value);
    }
    return parsedUrl.href;
  }

  function parseResponseHeaders(rawHeaders) {
    const headers = {};
    if (!rawHeaders) return headers;

    for (const line of rawHeaders.split(/\r?\n/)) {
      const separatorIndex = line.indexOf(":");
      if (separatorIndex === -1) continue;

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

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

  function buildResponseHeaders(rawHeaders, ruleHeaders, includeCredentials) {
    const headers = {
      ...CORS_HEADERS,
      ...ruleHeaders,
      ...parseResponseHeaders(rawHeaders),
    };

    if (includeCredentials) {
      headers["access-control-allow-credentials"] = "true";
      if (
        !headers["access-control-allow-origin"] ||
        headers["access-control-allow-origin"] === "*"
      ) {
        headers["access-control-allow-origin"] = pageOrigin;
      }
    }

    return headers;
  }

  function shouldIncludeCredentials(
    url,
    credentialsMode,
    forceInclude = false,
  ) {
    if (forceInclude || 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 formData = new FormData();
        for (const [key, value] of body) {
          formData.append(key, value);
        }
        return formData;
      }
      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: (error) => reject(new Error(error?.error || "Network error")),
        ontimeout: () => reject(new Error("Request timeout")),
      });
    });
  }

  function responseToArrayBuffer(response) {
    return response.response instanceof ArrayBuffer
      ? response.response
      : new TextEncoder().encode(response.responseText || "");
  }

  function getCompiledRegex(pattern) {
    if (regexCache.has(pattern)) {
      return regexCache.get(pattern);
    }
    try {
      const regex = new RegExp(pattern);
      regexCache.set(pattern, regex);
      return regex;
    } catch {
      regexCache.set(pattern, null);
      return null;
    }
  }

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

    const normalizedUrl = parsedUrl.href;
    const hostname = parsedUrl.hostname;

    for (const rule of proxyRules.values()) {
      if (rule.targetDomains?.length) {
        const domainMatches = rule.targetDomains.some(
          (domain) => hostname === domain || hostname.endsWith(`.${domain}`),
        );
        if (domainMatches) return rule;
      }

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

    return null;
  }

  function isStreamingContent(contentType, url) {
    return (
      STREAMING_MIME_TYPES.some((mimeType) => contentType.includes(mimeType)) ||
      STREAMING_EXTENSIONS_REGEX.test(url)
    );
  }

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

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

  function removeRuleCachedRegex(ruleId) {
    const existingRule = proxyRules.get(ruleId);
    if (existingRule?.targetRegex) {
      regexCache.delete(existingRule.targetRegex);
    }
  }

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

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

    if (proxyCache.has(normalizedUrl)) {
      return proxyCache.get(normalizedUrl);
    }

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

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

        return createBlobUrl(responseToArrayBuffer(response), contentType);
      } catch (error) {
        logWarning("Media proxy failed:", error.message);
        return null;
      } finally {
        setTimeout(() => proxyCache.delete(normalizedUrl), 1000);
      }
    })();

    proxyCache.set(normalizedUrl, proxyPromise);
    return proxyPromise;
  }

  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 includeCredentials = shouldIncludeCredentials(
        url,
        init.credentials,
      );

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

        return new Response(responseToArrayBuffer(response), {
          status: response.status,
          statusText: response.statusText || "",
          headers: buildResponseHeaders(
            response.responseHeaders,
            rule.responseHeaders,
            includeCredentials,
          ),
        });
      } catch (error) {
        logWarning("Fetch proxy failed:", error.message);
        return nativeFetch(input, init);
      }
    };
  }

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

    const NativeXMLHttpRequest = globalContext.XMLHttpRequest;

    class ProxyXMLHttpRequest {
      constructor() {
        this._nativeXhr = new NativeXMLHttpRequest();
        this._useNative = true;
        this._eventListeners = new Map();
        this._requestHeaders = {};
        this._responseHeaders = {};
        this._matchedRule = null;
        this._requestUrl = "";
        this._requestMethod = "GET";
        this._isAborted = false;
        this._timeoutId = null;
        this._overriddenMimeType = "";
        this._nativeEventsBound = false;

        this.readyState = XHR_STATES.UNSENT;
        this.status = 0;
        this.statusText = "";
        this.response = null;
        this.responseText = "";
        this.responseURL = "";
        this.responseType = "";
        this.withCredentials = false;
        this.timeout = 0;
        this.upload = this._nativeXhr.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;
      }

      _emitEvent(eventType, event = new Event(eventType)) {
        const handler = this[`on${eventType}`];
        if (handler) {
          try {
            handler.call(this, event);
          } catch (error) {
            logError("XHR handler error:", error);
          }
        }

        const listeners = this._eventListeners.get(eventType);
        if (listeners) {
          for (const listener of listeners) {
            try {
              listener.call(this, event);
            } catch (error) {
              logError("XHR listener error:", error);
            }
          }
        }
      }

      _syncFromNative() {
        if (!this._useNative) return;

        try {
          this.readyState = this._nativeXhr.readyState;

          if (this.readyState >= XHR_STATES.HEADERS_RECEIVED) {
            this.status = this._nativeXhr.status;
            this.statusText = this._nativeXhr.statusText;
          }

          if (this.readyState === XHR_STATES.DONE) {
            this.response = this._nativeXhr.response;
            this.responseURL = this._nativeXhr.responseURL;

            const responseType = this._nativeXhr.responseType;
            if (!responseType || responseType === "text") {
              this.responseText = this._nativeXhr.responseText;
            }
          }
        } catch {}
      }

      _bindNativeEvents() {
        if (this._nativeEventsBound) return;
        this._nativeEventsBound = true;

        for (const eventType of XHR_EVENT_TYPES) {
          this._nativeXhr.addEventListener(eventType, (event) => {
            this._syncFromNative();
            this._emitEvent(eventType, event);
          });
        }
      }

      _setResponseData(buffer) {
        const contentType =
          this.getResponseHeader("content-type") ||
          this._overriddenMimeType ||
          "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 = new TextDecoder().decode(buffer);
            this.responseText = text;
            try {
              this.response = JSON.parse(text);
            } catch {
              this.response = null;
            }
            break;
          }

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

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

      addEventListener(eventType, callback) {
        if (!this._eventListeners.has(eventType)) {
          this._eventListeners.set(eventType, []);
        }
        this._eventListeners.get(eventType).push(callback);

        if (this._useNative) {
          this._nativeXhr.addEventListener(eventType, callback);
        }
      }

      removeEventListener(eventType, callback) {
        const listeners = this._eventListeners.get(eventType);
        if (listeners) {
          const index = listeners.indexOf(callback);
          if (index !== -1) listeners.splice(index, 1);
        }

        if (this._useNative) {
          this._nativeXhr.removeEventListener(eventType, callback);
        }
      }

      dispatchEvent(event) {
        if (this._useNative) {
          return this._nativeXhr.dispatchEvent(event);
        }
        this._emitEvent(event.type, event);
        return !event.defaultPrevented;
      }

      open(method, url, async = true, username, password) {
        this._requestMethod = method;
        this._requestUrl = normalizeUrl(url) || url;
        this._matchedRule = findMatchingRule(this._requestUrl);
        this._useNative = !this._matchedRule;

        if (this._useNative) {
          return this._nativeXhr.open(method, url, async, username, password);
        }

        this.readyState = XHR_STATES.OPENED;
        this._emitEvent("readystatechange");
      }

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

      getResponseHeader(name) {
        if (this._useNative) {
          return this._nativeXhr.getResponseHeader(name);
        }
        return this._responseHeaders[name?.toLowerCase()] ?? null;
      }

      getAllResponseHeaders() {
        if (this._useNative) {
          return this._nativeXhr.getAllResponseHeaders();
        }
        return Object.entries(this._responseHeaders)
          .map(([key, value]) => `${key}: ${value}`)
          .join("\r\n");
      }

      overrideMimeType(mimeType) {
        if (this._useNative) {
          return this._nativeXhr.overrideMimeType(mimeType);
        }
        this._overriddenMimeType = mimeType;
      }

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

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

        this._isAborted = true;
        this.readyState = XHR_STATES.UNSENT;
        this._emitEvent("abort");
      }

      async send(body = null) {
        if (this._useNative) {
          this._nativeXhr.withCredentials = this.withCredentials;
          this._nativeXhr.responseType = this.responseType;
          this._nativeXhr.timeout = this.timeout;
          this._bindNativeEvents();
          return this._nativeXhr.send(body);
        }

        const rule = this._matchedRule;
        const url = this._requestUrl;
        const method = this._requestMethod;

        const headers = { ...rule.requestHeaders, ...this._requestHeaders };
        const includeCredentials = shouldIncludeCredentials(
          url,
          this.withCredentials ? "include" : undefined,
          this.withCredentials,
        );
        const binaryResponse =
          this.responseType === "arraybuffer" || this.responseType === "blob";

        const requestPromise = executeGmRequest({
          url,
          method,
          headers,
          data: normalizeRequestBody(body),
          responseType: binaryResponse ? "arraybuffer" : "text",
          withCredentials: includeCredentials,
        });

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

        this._emitEvent("loadstart");

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

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

          if (this._isAborted) return;

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

          const buffer = responseToArrayBuffer(response);

          this.readyState = XHR_STATES.HEADERS_RECEIVED;
          this._emitEvent("readystatechange");

          this.readyState = XHR_STATES.LOADING;
          this._emitEvent("readystatechange");

          this._setResponseData(buffer);

          this.readyState = XHR_STATES.DONE;
          this._emitEvent("readystatechange");
          this._emitEvent("load");
          this._emitEvent("loadend");
        } catch (error) {
          if (this._timeoutId) {
            clearTimeout(this._timeoutId);
            this._timeoutId = null;
          }

          if (this._isAborted) return;

          this.status = 0;
          this.statusText = error.message || "";
          this.readyState = XHR_STATES.DONE;

          this._emitEvent("readystatechange");
          this._emitEvent(error.message === "timeout" ? "timeout" : "error");
          this._emitEvent("loadend");
        }
      }
    }

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

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

    const mediaPrototype = globalContext.HTMLMediaElement.prototype;
    const srcDescriptor = Object.getOwnPropertyDescriptor(
      mediaPrototype,
      "src",
    );
    const nativeSetAttribute = mediaPrototype.setAttribute;

    if (srcDescriptor?.set) {
      const originalSetter = srcDescriptor.set;

      Object.defineProperty(mediaPrototype, "src", {
        ...srcDescriptor,
        set(value) {
          const element = this;

          if (typeof value !== "string") {
            originalSetter.call(element, value);
            return;
          }

          const normalizedValue = normalizeUrl(value);
          if (!normalizedValue) {
            originalSetter.call(element, value);
            return;
          }

          originalSetter.call(element, value);

          proxyMediaSource(value)
            .then((proxiedUrl) => {
              if (proxiedUrl && element.src === normalizedValue) {
                originalSetter.call(element, proxiedUrl);
              }
            })
            .catch(() => {});
        },
      });
    }

    mediaPrototype.setAttribute = function (name, value) {
      const element = this;

      if (name?.toLowerCase() !== "src" || typeof value !== "string") {
        return nativeSetAttribute.call(element, name, value);
      }

      const normalizedValue = normalizeUrl(value);
      if (!normalizedValue) {
        return nativeSetAttribute.call(element, "src", value);
      }

      nativeSetAttribute.call(element, "src", value);

      proxyMediaSource(value)
        .then((proxiedUrl) => {
          if (proxiedUrl && element.src === normalizedValue) {
            nativeSetAttribute.call(element, "src", proxiedUrl);
          }
        })
        .catch(() => {});
    };

    globalContext.addEventListener("beforeunload", () => {
      for (const url of blobUrlRegistry) {
        URL.revokeObjectURL(url);
      }
      blobUrlRegistry.clear();
    });
  }

  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 includeCredentials = 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: includeCredentials,
      });

      const headers = buildResponseHeaders(
        response.responseHeaders,
        null,
        includeCredentials,
      );
      const text = new 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();
      removeRuleCachedRegex(body.ruleId);

      const responseHeaders = {};
      if (body.responseHeaders) {
        for (const [key, value] of Object.entries(body.responseHeaders)) {
          const normalizedKey = key.toLowerCase();
          if (MODIFIABLE_HEADERS.has(normalizedKey)) {
            responseHeaders[normalizedKey] = value;
          }
        }
      }

      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(messageName, handler) {
    globalContext.addEventListener("message", async (event) => {
      if (
        event.source !== globalContext ||
        event.data?.name !== messageName ||
        event.data?.relayed
      ) {
        return;
      }

      const { instanceId, body } = event.data;

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

  for (const [name, handler] of Object.entries(messageHandlers)) {
    setupMessageRelay(name, handler);
  }
})();