P-Stream Userscript

Userscript replacement for the P-Stream extension

이 스크립트를 설치하려면 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 Userscript
// @namespace    https://pstream.mov/
// @version      1.0.1
// @description  Userscript replacement for the P-Stream extension
// @author       Duplicake, P-Stream Team
// @icon         https://raw.githubusercontent.com/p-stream/p-stream/production/public/mstile-150x150.jpeg
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-start
// @connect      *
// ==/UserScript==

(function () {
  'use strict';

  // Environment bootstrap, report higher version to bypass extension version requirement.
  const SCRIPT_VERSION = '1.4.0';
  // Use unsafeWindow when available so our patches run in the page context.
  const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  const gmXhr =
    typeof GM_xmlhttpRequest === 'function'
      ? GM_xmlhttpRequest
      : typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function'
        ? GM.xmlHttpRequest
        : null;

  // --- Constants & state -------------------------------------------------
  const DEFAULT_CORS_HEADERS = {
    'access-control-allow-origin': '*',
    'access-control-allow-methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
    'access-control-allow-headers': '*',
  };
  const MODIFIABLE_RESPONSE_HEADERS = [
    'access-control-allow-origin',
    'access-control-allow-methods',
    'access-control-allow-headers',
    'content-security-policy',
    'content-security-policy-report-only',
    'content-disposition',
  ];

  const STREAM_RULES = new Map();
  const MEDIA_BLOBS = new Map();
  const PROXY_CACHE = new Map();
  let fetchPatched = false;
  let xhrPatched = false;
  let mediaPatched = false;

  const REQUEST_ORIGIN = (() => {
    try {
      const { origin, href } = pageWindow.location;
      if (origin && origin !== 'null') return origin;
      if (href) return new URL(href).origin;
    } catch {}
    return '*';
  })();

  // --- Logging -----------------------------------------------------------
  const log = (...args) => console.debug('[p-stream-userscript]', ...args);

  // --- Basic utilities ---------------------------------------------------
  const canAccessCookies = () => true;

  const normalizeUrl = (input) => {
    if (!input) return null;
    try {
      return new URL(input, pageWindow.location.href).toString();
    } catch {
      return null;
    }
  };

  const isSameOrigin = (url) => {
    try {
      return new URL(url).origin === new URL(pageWindow.location.href).origin;
    } catch {
      return false;
    }
  };

  const makeFullUrl = (url, ops = {}) => {
    let leftSide = ops.baseUrl ?? '';
    let rightSide = url;
    if (leftSide.length > 0 && !leftSide.endsWith('/')) leftSide += '/';
    if (rightSide.startsWith('/')) rightSide = rightSide.slice(1);
    const fullUrl = leftSide + rightSide;
    if (!fullUrl.startsWith('http://') && !fullUrl.startsWith('https://'))
      throw new Error(`Invalid URL -- URL doesn't start with a http scheme: '${fullUrl}'`);

    const parsedUrl = new URL(fullUrl);
    Object.entries(ops.query ?? {}).forEach(([k, v]) => parsedUrl.searchParams.set(k, v));
    return parsedUrl.toString();
  };

  const parseHeaders = (raw) => {
    const headers = {};
    (raw || '')
      .split(/\r?\n/)
      .filter(Boolean)
      .forEach((line) => {
        const idx = line.indexOf(':');
        if (idx === -1) return;
        const key = line.slice(0, idx).trim();
        const value = line.slice(idx + 1).trim();
        headers[key.toLowerCase()] = headers[key.toLowerCase()]
          ? `${headers[key.toLowerCase()]}, ${value}`
          : value;
      });
    return headers;
  };

  const buildResponseHeaders = (rawHeaders, ruleHeaders, includeCredentials) => {
    const headerMap = {
      ...DEFAULT_CORS_HEADERS,
      ...(ruleHeaders ?? {}),
      ...parseHeaders(rawHeaders),
    };

    if (includeCredentials) {
      headerMap['access-control-allow-credentials'] = 'true';
      if (!headerMap['access-control-allow-origin'] || headerMap['access-control-allow-origin'] === '*') {
        headerMap['access-control-allow-origin'] = REQUEST_ORIGIN;
      }
    }

    return headerMap;
  };

  // --- Request helpers ---------------------------------------------------
  const mapBodyToPayload = (body, bodyType) => {
    if (body == null) return undefined;
    switch (bodyType) {
      case 'FormData': {
        const formData = new FormData();
        body.forEach(([key, value]) => formData.append(key, value));
        return formData;
      }
      case 'URLSearchParams':
        return new URLSearchParams(body);
      case 'object':
        return JSON.stringify(body);
      case 'string':
        return body;
      default:
        return body;
    }
  };

  const normalizeBody = (body) => {
    if (body == null) return undefined;
    if (body instanceof URLSearchParams) return body.toString();
    if (typeof body === 'string' || body instanceof FormData || body instanceof Blob) return body;
    if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return body;
    if (typeof body === 'object') return JSON.stringify(body);
    return body;
  };

  const gmRequest = (options) =>
    new Promise((resolve, reject) => {
      if (!gmXhr) {
        reject(new Error('GM_xmlhttpRequest missing; cannot proxy request'));
        return;
      }
      gmXhr({
        ...options,
        onload: (response) => resolve(response),
        onerror: (error) => reject(error),
        ontimeout: () => reject(new Error('Request timed out')),
      });
    });

  const shouldSendCredentials = (url, credentialsMode, withCredentialsFlag = false) => {
    if (!url) return false;
    if (withCredentialsFlag) return true;
    const sameOrigin = isSameOrigin(url);

    if (credentialsMode === 'omit') return false;
    if (credentialsMode === 'include') return true;
    if (!credentialsMode || credentialsMode === 'same-origin') return sameOrigin || canAccessCookies();
    return canAccessCookies();
  };

  const findRuleForUrl = (url) => {
    const normalized = normalizeUrl(url);
    if (!normalized) return null;
    const host = new URL(normalized).hostname;
    for (const rule of STREAM_RULES.values()) {
      if (rule.targetDomains?.some((d) => host === d || host.endsWith(`.${d}`))) return rule;
      if (rule.targetRegex) {
        try {
          const regex = new RegExp(rule.targetRegex);
          if (regex.test(normalized)) return rule;
        } catch (err) {
          log('Invalid targetRegex in rule, skipping', err);
        }
      }
    }
    return null;
  };

  // --- Media helpers -----------------------------------------------------
  const makeBlobUrl = (data, contentType) => {
    const blob = new Blob([data], { type: contentType || 'application/octet-stream' });
    return URL.createObjectURL(blob);
  };

  const proxyMediaIfNeeded = async (url) => {
    const normalized = normalizeUrl(url);
    if (!normalized) return null;
    
    // Check cache first
    if (PROXY_CACHE.has(normalized)) {
      return PROXY_CACHE.get(normalized);
    }
    
    const rule = findRuleForUrl(normalized);
    if (!rule) return null;
    
    // Create promise and cache it immediately to prevent duplicate requests
    const proxyPromise = (async () => {
      try {
        const includeCredentials = shouldSendCredentials(normalized, 'include', true);
        const response = await gmRequest({
          url: normalized,
          method: 'GET',
          headers: rule.requestHeaders,
          responseType: 'arraybuffer',
          withCredentials: includeCredentials,
        });
        const headers = parseHeaders(response.responseHeaders);
        const contentType = headers['content-type'] || '';

        if (
          contentType.includes('application/vnd.apple.mpegurl') ||
          contentType.includes('application/x-mpegurl') ||
          normalized.includes('.m3u8')
        ) {
          return null;
        }
        if (contentType.includes('application/dash+xml') || normalized.includes('.mpd')) return null;

        const blobUrl = makeBlobUrl(
          response.response instanceof ArrayBuffer ? response.response : new TextEncoder().encode(response.responseText ?? ''),
          contentType,
        );
        MEDIA_BLOBS.set(blobUrl, true);
        return blobUrl;
      } catch (err) {
        log('Media proxy failed, falling back to original src', err);
        return null;
      } finally {
        // Remove from cache after a short delay
        setTimeout(() => PROXY_CACHE.delete(normalized), 1000);
      }
    })();
    
    PROXY_CACHE.set(normalized, proxyPromise);
    return proxyPromise;
  };

  // --- Proxy initializers ------------------------------------------------
  const ensureFetchProxy = () => {
    if (fetchPatched) return;
    fetchPatched = true;
    const win = pageWindow;
    const nativeFetch = win.fetch.bind(win);

    win.fetch = async (input, init = {}) => {
      const targetUrl = normalizeUrl(typeof input === 'string' ? input : input?.url);
      if (!targetUrl) return nativeFetch(input, init);
      const rule = findRuleForUrl(targetUrl);
      if (!rule) return nativeFetch(input, init);

      const headers = {};
      const initHeaders = init.headers instanceof Headers ? Object.fromEntries(init.headers.entries()) : init.headers;
      Object.assign(headers, rule.requestHeaders ?? {}, initHeaders ?? {});

      const method = init.method || 'GET';
      const payload = normalizeBody(init.body);
      const includeCredentials = shouldSendCredentials(targetUrl, init.credentials);

      try {
        const response = await gmRequest({
          url: targetUrl,
          method,
          data: payload,
          headers,
          responseType: 'arraybuffer',
          withCredentials: includeCredentials,
        });

        const headerMap = buildResponseHeaders(response.responseHeaders, rule.responseHeaders, includeCredentials);
        const bodyBuffer =
          response.response instanceof ArrayBuffer
            ? response.response
            : new TextEncoder().encode(response.responseText ?? '');

        return new Response(bodyBuffer, {
          status: response.status,
          statusText: response.statusText ?? '',
          headers: headerMap,
        });
      } catch (err) {
        log('Proxy fetch failed, falling back to native', err);
        return nativeFetch(input, init);
      }
    };
  };

  const ensureXhrProxy = () => {
    if (xhrPatched) return;
    xhrPatched = true;
    const win = pageWindow;
    const NativeXHR = win.XMLHttpRequest;

    const EVENTS = ['readystatechange', 'load', 'error', 'timeout', 'abort', 'loadend', 'progress', 'loadstart'];

    const emit = (instance, type, event = new Event(type)) => {
      try {
        instance[`on${type}`]?.call(instance, event);
      } catch (err) {
        log('XHR handler error', err);
      }
      (instance._listeners.get(type) || []).forEach((cb) => {
        try {
          cb.call(instance, event);
        } catch (err) {
          log('XHR listener error', err);
        }
      });
    };

    class ProxyXHR {
      constructor() {
        this._native = new NativeXHR();
        this._usingNative = true;
        this._listeners = new Map();
        this._headers = {};
        this._rule = null;
        this._url = '';
        this._method = 'GET';
        this._responseHeaders = {};
        this._readyState = ProxyXHR.UNSENT;
        this._status = 0;
        this._statusText = '';
        this._response = null;
        this._responseText = '';
        this._responseURL = '';
        this._overrideMime = '';
        this.responseType = '';
        this.withCredentials = false;
        this.timeout = 0;
        this.upload = this._native.upload;
      }

      get readyState() {
        return this._usingNative ? this._native.readyState : this._readyState;
      }

      set readyState(value) {
        this._readyState = value;
      }

      get status() {
        return this._usingNative ? this._native.status : this._status;
      }

      set status(value) {
        this._status = value;
      }

      get statusText() {
        return this._usingNative ? this._native.statusText : this._statusText;
      }

      set statusText(value) {
        this._statusText = value;
      }

      get response() {
        return this._usingNative ? this._native.response : this._response;
      }

      set response(value) {
        this._response = value;
      }

      get responseText() {
        return this._usingNative ? this._native.responseText : this._responseText;
      }

      set responseText(value) {
        this._responseText = value;
      }

      get responseURL() {
        return this._usingNative ? this._native.responseURL : this._responseURL;
      }

      set responseURL(value) {
        this._responseURL = value;
      }

      addEventListener(type, callback) {
        if (!this._listeners.has(type)) this._listeners.set(type, []);
        this._listeners.get(type).push(callback);
        if (this._usingNative) return this._native.addEventListener(type, callback);
      }

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

      _bindNativeEvents() {
        if (this._nativeBound) return;
        this._nativeBound = true;
        EVENTS.forEach((type) => {
          this._native.addEventListener(type, (event) => emit(this, type, event));
        });
      }

      open(method, url, async = true, user, password) {
        this._method = method;
        const normalized = normalizeUrl(url);
        this._url = normalized ?? url;
        this._rule = normalized ? findRuleForUrl(normalized) : null;
        this._usingNative = !this._rule;

        if (this._usingNative) {
          return this._native.open(method, url, async, user, password);
        }

        this.readyState = ProxyXHR.OPENED;
        emit(this, 'readystatechange');
      }

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

      getResponseHeader(name) {
        if (this._usingNative) return this._native.getResponseHeader(name);
        const key = name?.toLowerCase?.() ?? '';
        return this._responseHeaders[key] ?? null;
      }

      getAllResponseHeaders() {
        if (this._usingNative) return this._native.getAllResponseHeaders();
        return Object.entries(this._responseHeaders)
          .map(([k, v]) => `${k}: ${v}`)
          .join('\r\n');
      }

      overrideMimeType(mime) {
        if (this._usingNative) return this._native.overrideMimeType(mime);
        this._overrideMime = mime;
      }

      abort() {
        if (this._usingNative) return this._native.abort();
        if (this._timeoutId) clearTimeout(this._timeoutId);
        this._aborted = true;
        this.readyState = ProxyXHR.UNSENT;
        emit(this, 'abort');
      }

      _applyTimeout(promise) {
        if (!this.timeout) return promise;
        return Promise.race([
          promise,
          new Promise((_, reject) => {
            this._timeoutId = setTimeout(() => reject(new Error('timeout')), this.timeout);
          }),
        ]);
      }

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

        const rule = this._rule;
        if (!rule) return;
        const headers = { ...(rule.requestHeaders ?? {}), ...this._headers };
        const includeCredentials = shouldSendCredentials(this._url, this.withCredentials ? 'include' : undefined, this.withCredentials);

        try {
          emit(this, 'loadstart');
          const response = await this._applyTimeout(
            gmRequest({
              url: this._url,
              method: this._method || 'GET',
              data: normalizeBody(body),
              headers,
              responseType: this.responseType === 'arraybuffer' || this.responseType === 'blob' ? 'arraybuffer' : 'text',
              withCredentials: includeCredentials,
            }),
          );

          if (this._timeoutId) clearTimeout(this._timeoutId);
          if (this._aborted) return;

          const headerMap = buildResponseHeaders(response.responseHeaders, rule.responseHeaders, includeCredentials);
          this._responseHeaders = Object.fromEntries(Object.entries(headerMap).map(([k, v]) => [k.toLowerCase(), v]));

          const responseUrl = response.finalUrl || this._url;
          this.responseURL = responseUrl;
          this.status = response.status;
          this.statusText = response.statusText ?? '';
          const bodyBuffer =
            response.response instanceof ArrayBuffer
              ? response.response
              : new TextEncoder().encode(response.responseText ?? '');

          this.readyState = ProxyXHR.HEADERS_RECEIVED;
          emit(this, 'readystatechange');
          this.readyState = ProxyXHR.LOADING;
          emit(this, 'readystatechange');

          if (this.responseType === 'arraybuffer') {
            this.response = bodyBuffer;
          } else if (this.responseType === 'blob') {
            this.response = new Blob([bodyBuffer], {
              type: this.getResponseHeader('content-type') || this._overrideMime || 'application/octet-stream',
            });
          } else if (this.responseType === 'json') {
            const text = new TextDecoder().decode(bodyBuffer);
            this.responseText = text;
            try {
              this.response = JSON.parse(text);
            } catch {
              this.response = null;
            }
          } else {
            this.response = new TextDecoder().decode(bodyBuffer);
            this.responseText = this.response;
          }

          this.readyState = ProxyXHR.DONE;
          emit(this, 'readystatechange');
          emit(this, 'load');
          emit(this, 'loadend');
        } catch (err) {
          if (this._timeoutId) clearTimeout(this._timeoutId);
          if (this._aborted) return;
          this.status = 0;
          this.statusText = err?.message ?? '';
          this.readyState = ProxyXHR.DONE;
          emit(this, 'readystatechange');
          emit(this, err?.message === 'timeout' ? 'timeout' : 'error');
          emit(this, 'loadend');
        }
      }
    }

    ProxyXHR.UNSENT = 0;
    ProxyXHR.OPENED = 1;
    ProxyXHR.HEADERS_RECEIVED = 2;
    ProxyXHR.LOADING = 3;
    ProxyXHR.DONE = 4;

    win.XMLHttpRequest = ProxyXHR;
  };

  const ensureMediaProxy = () => {
    if (mediaPatched) return;
    mediaPatched = true;
    const win = pageWindow;

    const srcDescriptor = Object.getOwnPropertyDescriptor(win.HTMLMediaElement.prototype, 'src');
    if (srcDescriptor && srcDescriptor.set) {
      Object.defineProperty(win.HTMLMediaElement.prototype, 'src', {
        ...srcDescriptor,
        set(value) {
          if (typeof value === 'string') {
            // Start proxying in background but set original URL immediately
            proxyMediaIfNeeded(value).then(proxied => {
              if (proxied && this.src === value) {
                // Only update if src hasn't changed
                srcDescriptor.set.call(this, proxied);
              }
            });
            return srcDescriptor.set.call(this, value);
          }
          return srcDescriptor.set.call(this, value);
        },
      });
    }

    // CRITICAL FIX: Keep setAttribute synchronous
    const originalMediaSetAttribute = win.HTMLMediaElement.prototype.setAttribute;
    win.HTMLMediaElement.prototype.setAttribute = function (name, value) {
      if (typeof name === 'string' && name.toLowerCase() === 'src' && typeof value === 'string') {
        // Start proxying in background but set attribute immediately
        proxyMediaIfNeeded(value).then(proxied => {
          if (proxied && this.getAttribute('src') === value) {
            // Only update if src attribute hasn't changed
            originalMediaSetAttribute.call(this, name, proxied);
          }
        });
      }
      return originalMediaSetAttribute.call(this, name, value);
    };

    win.addEventListener('beforeunload', () => {
      MEDIA_BLOBS.forEach((_, blobUrl) => URL.revokeObjectURL(blobUrl));
      MEDIA_BLOBS.clear();
    });
  };

  const ensureAllProxies = () => {
    ensureFetchProxy();
    ensureXhrProxy();
    ensureMediaProxy();
  };

  // --- Cleanup helper ----------------------------------------------------
  const cleanupOldStreamData = () => {
    // Clear old blob URLs
    MEDIA_BLOBS.forEach((_, blobUrl) => {
      try {
        URL.revokeObjectURL(blobUrl);
      } catch (err) {
        log('Failed to revoke blob URL', err);
      }
    });
    MEDIA_BLOBS.clear();
    PROXY_CACHE.clear();
    log('Cleaned up old stream data');
  };

  // --- Message handlers --------------------------------------------------
  const handleHello = async () => ({
    success: true,
    version: SCRIPT_VERSION,
    allowed: true,
    hasPermission: true,
  });

  const handleMakeRequest = async (reqBody) => {
    if (!reqBody) throw new Error('No request body found in the request.');
    const url = makeFullUrl(reqBody.url, reqBody);
    const includeCredentials = shouldSendCredentials(url, reqBody.credentials, reqBody.withCredentials);

    const response = await gmRequest({
      url,
      method: reqBody.method || 'GET',
      headers: reqBody.headers,
      data: mapBodyToPayload(reqBody.body, reqBody.bodyType),
      responseType: 'arraybuffer',
      withCredentials: includeCredentials,
    });

    const headers = buildResponseHeaders(response.responseHeaders, null, includeCredentials);
    const contentType = headers['content-type'] || '';
    let parsedBody;

    try {
      if (contentType.includes('application/json')) {
        const textBody =
          response.response instanceof ArrayBuffer
            ? new TextDecoder().decode(response.response)
            : response.responseText ?? '';
        parsedBody = JSON.parse(textBody);
      } else if (response.response instanceof ArrayBuffer) {
        parsedBody = new TextDecoder().decode(response.response);
      } else {
        parsedBody = response.responseText ?? '';
      }
    } catch (err) {
      log('Failed to parse response body, returning raw text', err);
      parsedBody = response.responseText ?? '';
    }

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

  const handlePrepareStream = async (reqBody) => {
    if (!reqBody) throw new Error('No request body found in the request.');
    
    // Clean up old stream data before preparing new stream
    cleanupOldStreamData();
    
    const responseHeaders = Object.entries(reqBody.responseHeaders ?? {}).reduce((acc, [k, v]) => {
      const key = k.toLowerCase();
      if (MODIFIABLE_RESPONSE_HEADERS.includes(key)) acc[key] = v;
      return acc;
    }, {});

    STREAM_RULES.set(reqBody.ruleId, {
      ...reqBody,
      responseHeaders,
    });
    
    log('Stream prepared:', reqBody.ruleId);
    ensureAllProxies();
    return { success: true };
  };

  const handleOpenPage = async (reqBody) => {
    if (reqBody?.redirectUrl) {
      window.location.href = reqBody.redirectUrl;
    }
    return { success: true };
  };

  // --- Messaging bridge --------------------------------------------------
  const shouldHandleMessage = (event, config) => {
    if (config.__internal) return false;
    if (event.source !== pageWindow) return false;
    if (event.data?.name !== config.name) return false;
    if (config.relayId !== undefined && event.data?.relayId !== config.relayId) return false;
    return true;
  };

  const relay = (config, handler) => {
    const listener = async (event) => {
      if (!shouldHandleMessage(event, config)) return;
      if (event.data?.relayed) return;

      try {
        const result = await handler?.(event.data?.body);
        pageWindow.postMessage(
          {
            name: config.name,
            relayId: config.relayId,
            instanceId: event.data?.instanceId,
            body: result,
            relayed: true,
          },
          config.targetOrigin || '/',
        );
      } catch (err) {
        pageWindow.postMessage(
          {
            name: config.name,
            relayId: config.relayId,
            instanceId: event.data?.instanceId,
            body: {
              success: false,
              error: err instanceof Error ? err.message : String(err),
            },
            relayed: true,
          },
          config.targetOrigin || '/',
        );
      }
    };

    pageWindow.addEventListener('message', listener);
    return () => pageWindow.removeEventListener('message', listener);
  };

  relay({ name: 'hello' }, handleHello);
  relay({ name: 'makeRequest' }, handleMakeRequest);
  relay({ name: 'prepareStream' }, handlePrepareStream);
  relay({ name: 'openPage' }, handleOpenPage);

  log('Userscript proxy loaded');
})();