JavaRush /Курсы /ChatGPT Apps /Взаимодействие с сервером: window.fetch и openExternal

Взаимодействие с сервером: window.fetch и openExternal

ChatGPT Apps
3 уровень , 4 лекция
Открыта

1. Два пути наружу: навигация и данные

Если обычный Next.js‑разработчик слышит «нужно сходить на сервер», рука автоматически тянется к fetch или к любимому HTTP‑клиенту. В мире ChatGPT Apps такая рефлекторная реакция приводит к боли.

В части курса, посвящённой безопасности виджетов в ChatGPT Apps, мы предлагаем с самого начала сломать старый рефлекс. Виджет не живёт в свободном интернете: он сидит в жёсткой изоляции, а сетевой доступ к нему фильтруется и ограничивается политиками хоста.

У виджета есть всего три базовых окошка наружу:

  1. Навигация: отправить пользователя куда‑то во внешний мир. Для этого есть openExternal.
  2. Обмен данными: получить/отправить JSON, поговорить с backend’ом. Это делается через fetch, но возможны сильные ограничения.
  3. MCP tool call: вызов инструментов (MCP / backend), которые не имеют никаких ограничений.

В этой лекции мы фокусируемся на первом и самом безопасном пути (навигация) и аккуратно знакомимся с контролируемым fetch. В следующих модулях мы разберём MCP и инструменты как основной способ серьёзного общения с сервером.

2. openExternal: безопасный «телепорт» пользователя

Почему нельзя просто сделать window.open

В обычном веб‑приложении вы бы сделали примерно так:


window.open("https://example.com", "_blank");

В песочнице ChatGPT это либо не сработает, либо сработает как‑то очень странно. Виджет — это заизолированный iframe с жёстким sandbox, который не имеет тех же прав, что вкладка браузера.

Кроме того, ChatGPT хост хочет контролировать, куда и когда вы ведёте пользователя, чтобы:

  • не допустить скрытого трекинга;
  • показать пользователю понятный UI подтверждения (особенно в мобильных/десктоп‑клиентах);
  • обеспечить одинаковое поведение ссылок в разных окружениях (web, десктоп, мобильное приложение).

Поэтому был придуман специальный API openExternal, доступный через window.openai или более удобный React‑хук useOpenExternal.

Как выглядит useOpenExternal

В официальных примерах Apps SDK хук useOpenExternal реализован примерно так:


export function useOpenExternal() {
  const openExternal = useCallback((href: string) => {
    if (typeof window === "undefined") return;

    if (window?.openai?.openExternal) {
      try {
        window.openai.openExternal({ href });
        return;
      } catch (error) {
        console.warn("openExternal failed, falling back to window.open", error);
      }
    }

    window.open(href, "_blank", "noopener,noreferrer");
  }, []);

  return openExternal;
}

Главная мысль здесь простая. Сначала мы пытаемся воспользоваться нативным механизмом ChatGPT (window.openai.openExternal). Если виджет вдруг рендерится не в ChatGPT (например, вы открыли его просто в браузере при разработке), аккуратно падаем обратно на обычный window.open.

В вашем приложении этот хук уже есть в шаблоне (если вы взяли стандартный репозиторий от OpenAI), и пользоваться им нужно именно так — а не лезть руками в window.openai.

Пример: кнопка «Посмотреть в магазине» в GiftGenius

Представим, что в toolOutput нашего GiftGenius приходят рекомендации с полем productUrl. Добавим к каждой карточке кнопку, которая откроет товар на вашем сайте:

import { useWidgetProps } from "../hooks/use-widget-props";
import { useOpenExternal } from "../hooks/use-open-external";

export function GiftListWidget() {
  const { toolOutput } = useWidgetProps<{
    recommendations: { id: string; title: string; price: string; url: string }[];
  }>();
  const openExternal = useOpenExternal();

  if (!toolOutput) return <p>Пока нет рекомендаций…</p>;

  return (
    <div>
      {toolOutput.recommendations.map((gift) => (
        <div key={gift.id} className="flex justify-between gap-2">
          <div>
            <div>{gift.title}</div>
            <div className="text-sm text-muted-foreground">{gift.price}</div>
          </div>
          <button onClick={() => openExternal(gift.url)}>
            Открыть
          </button>
        </div>
      ))}
    </div>
  );
}

С точки зрения пользователя: он нажимает кнопку, ChatGPT может показать системное окошко «Открыть внешний сайт?», после чего откроет вашу страницу в новой вкладке или в браузере по умолчанию. Вы не тянете туда никакие секреты, токены и т.п., просто отправляете человека «из чата на сайт».

3. window.fetch в песочнице: это не тот fetch, к которому вы привыкли

Что обычно ожидает фронтендер

Обычно логика такая: «Раз это браузер, значит, можно спокойно дернуть любой URL, у которого настроен CORS. В худшем случае получу ошибку, но попробовать-то можно.»

В экосистеме ChatGPT Apps это опасное заблуждение. Песочница вокруг виджета — не просто «мелкая придирка», а фундаментальное требование безопасности: чтобы виджет не мог трекать пользователя, ходить на произвольные домены, сканировать локальную сеть и вообще вести себя как мини‑браузер внутри браузера.

В этом же отчёте подчёркивается, что у виджета в Apps SDK произвольный сетевой доступ либо отсутствует, либо сильно ограничен — и это не баг, а сознательное архитектурное решение.

Как это выглядит на практике

В типичном окружении ChatGPT:

  • fetch может быть доступен, но только к ограниченному списку доменов (обычно вашему домену, на котором крутится App, и, возможно, к паре явно разрешённых API);
  • запросы могут идти через специальный прокси хоста, который фильтрует заголовки и URL;
  • некоторые методы (PUT, DELETE) или нестандартные заголовки могут блокироваться политиками безопасности.

При этом у вас всё ещё есть удобный путь: если ваш виджет и ваш backend живут на одном домене (как в шаблоне на Next.js, где и MCP‑сервер, и UI обслуживаются одним приложением), внутренние запросы fetch("/api/...") обычно будут разрешены.

Главное — не рассчитывать на то, что виджет сможет ходить на любой api в интернете. Всё «толстое» общение с внешними сервисами (Stripe, Notion, CRM и т.п.) должно происходить на стороне MCP/бэкенда, куда ChatGPT обращается как к доверенному ресурсу.

Insight

В виджете ChatGPT нужно сразу забыть про относительные пути и жить на абсолютных URL. Причина простая: ваш HTML не работает на том же домене, что backend. ChatGPT читает ваш html, кладёт его на свой хост и рендерит внутри изолированного iframe. Любой "/api/..." или "/static/logo.png" внезапно начинает резолвиться относительно домена ChatGPT, а не вашего приложения — и всё разваливается.

<base> тут почти не спасает. Эксперименально установлено, что если у виджета не задан widgetCSP, вы можете прописать <base href="https://my-app.dev/">: ресурсы подтянутся с вашего домена, но скрипты, по правилам песочницы, всё равно жить не будут. Но это работает только в Dev Mode.

Но как только вы задаёте нормальный openai/widgetCSP (а в проде его всё равно придётся задавать для review), платформа сбрасывает <base>, и игра заканчивается: ресурсы и скрипты грузятся только с доменов, разрешённых в CSP, причём уже по абсолютным ссылкам.

Рекомендация: в ChatGPT-виджете всё, что уходит наружу — fetch, картинки, CSS, ваши странички для openExternal — всегда строится как полный URL от базового домена приложения, который вы контролируете через конфиг/ENV, а не через относительные пути и <base>.

4. Архитектура: тонкий UI, толстый backend

Из ограничений fetch и общей песочницы вытекает более общий архитектурный принцип, который важен для всего курса. Мы уже несколько раз говорили эту мантру, но сейчас самое время закрепить: виджет — это тонкий UI‑слой. Он рендерит то, что уже подготовил backend (через MCP/tools), показывает реакции на действия пользователя и в крайнем случае делает пару небольших публичных запросов.

Всё, что связано с авторизацией, доступом к персональным данным, секретами и небанальной бизнес‑логикой, должно жить на стороне сервера. Документы по безопасности курса отдельно подчёркивают: фронтенд (React‑виджет) — «public place», зона нулевого доверия, и секреты там жить не должны.

Все мои исследования по текущей теме формулируют цель жёстко: «забить последний гвоздь в крышку гроба идеи “толстого клиента”» для ChatGPT Apps. Виджет — только голова, а тело и мозги — в MCP/бэкенде.

Поэтому:

  • openExternal — для навигации пользователя на ваш «нормальный» сайт, где можно крутить уже привычный SPA, личный кабинет и остальное;
  • callTool (следующий модуль) — основной способ передать модели задачу, которую выполнит ваш backend;
  • fetch из виджета — редкий герой для вспомогательных, безопасных и желательно публичных запросов к вашему же приложению.

5. Практика: openExternal в нашем GiftGenius

Давайте чуть аккуратнее встроим openExternal в наш учебный App и заодно подумаем про UX.

Мини‑UX‑правило

Если вы ведёте пользователя наружу, полезно:

  • явно подсказать, куда именно он попадёт;
  • не делать неожиданных «прыжков» без объяснения в тексте (либо GPT сообщает «Открою сайт магазина…», либо вы подписываете кнопку).

Пример заголовка и подписи:

<button onClick={() => openExternal(gift.url)}>
  Открыть на сайте магазина
</button>

Пользователь понимает, что сейчас его выкинет из тёплого чатика в реальный мир с корзиной и оплатой.

Небольшой рефакторинг компонента списка

Ранее мы уже делали простой GiftListWidget. Предположим, что в предыдущих лекциях вы уже реализовали виджет, который показывает список подарков по toolOutput. Сейчас мы сделаем чуть более аккуратную версию: добавим тип Gift с полем url и кнопку openExternal.

type Gift = {
  id: string;
  title: string;
  priceLabel: string;
  url: string;
};

export function GiftListWidget() {
  const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
  const openExternal = useOpenExternal();

  if (!toolOutput || toolOutput.gifts.length === 0) {
    return <p>Пока ничего не нашёл. Попробуйте изменить запрос.</p>;
  }

  return (
    <div>
      {toolOutput.gifts.map((gift) => (
        <div key={gift.id} className="flex justify-between gap-2">
          <div>
            <div>{gift.title}</div>
            <div className="text-sm text-muted-foreground">
              {gift.priceLabel}
            </div>
          </div>
          <button onClick={() => openExternal(gift.url)}>
            Смотреть
          </button>
        </div>
      ))}
    </div>
  );
}

Мы по‑прежнему не работаем напрямую с window.openai, а пользуемся удобным хуком — он уже умеет падать обратно на window.open в тех случаях, когда ChatGPT‑окружения нет. Структура Gift здесь примерная — в своём App вы подстроите её под свой backend.

6. Практика: аккуратный fetch к нашему backend’у

Теперь давайте разберёмся с fetch. Напомню ещё раз: сложные или чувствительные операции лучше делать через инструменты/MCP. Но иногда хочется из виджета подтянуть что‑то лёгкое и публичное с вашего же сервера, например, список популярных категорий подарков.

Простой публичный API‑роут в Next.js

Добавим в наш Next.js‑проект такой обработчик:

// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";

const tags = ["Для детей", "Для путешественников", "Для геймеров"];

export async function GET() {
  return NextResponse.json({ tags });
}

Этот роут ничего не знает о пользователе, не требует токенов, не ходит во внешние сервисы — он просто отдаёт статический массив. Такой код можно почти без риска переносить и в продакшн, и в песочницу.

Вызов этого роута из виджета через fetch

Теперь в компоненте виджета добавим загрузку этих тегов. С учётом ограничений песочницы удобнее всего делать запрос на абсолютный URL: на тот же домен, где крутится ваш App — тот, который вы пробрасываете через туннель и регистрируете в Dev Mode ChatGPT (мы настраивали это в модуле про Dev Mode и туннель).

Важно: домен у вашего виджета будет что-то типа https://genius.web-sandbox.oaiusercontent.com, поэтому не используйте относительные пути для загрузки данных, только абсолютные. Пример:

import { useEffect, useState } from "react";

export function PopularTags() {
  const [tags, setTags] = useState<string[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;

    async function loadTags() {
      try {
        const res = await fetch("https://giftgenius.app/api/public/popular-tags");
        if (!res.ok) throw new Error("Bad status");
        const data: { tags: string[] } = await res.json();
        if (!cancelled) setTags(data.tags);
      } catch (e) {
        if (!cancelled) setError("Не удалось загрузить популярные категории");
      }
    }

    loadTags();
    return () => {
      cancelled = true;
    };
  }, []);

  if (error) return <p>{error}</p>;
  if (!tags) return <p>Загружаю популярные категории…</p>;

  return (
    <div className="flex flex-wrap gap-2 text-sm">
      {tags.map((tag) => (
        <span key={tag} className="rounded border px-2 py-1">
          {tag}
        </span>
      ))}
    </div>
  );
}

Важно, что:

  • мы аккуратно обрабатываем ошибки и показываем пользователю понятное сообщение;
  • не полагаемся на то, что fetch «точно сработает» — политики песочницы могут в любой момент перекрыть доступ, если вы измените домен или начнёте делать странные запросы;
  • не передаём сюда никаких токенов/секретов; если понадобится аутентификация — это уже задача MCP и модулей про Auth.

7. openExternal vs fetch vs инструменты (callTool): кто за что отвечает

Чтобы не путаться, удобно держать в голове такую «матрицу обязанностей»:

Сценарий Что используем Почему именно так
Открыть лендинг/товар/кабинет openExternal Явный переход пользователя, контролируемый хостом
Получить публичные данные с App fetch("my.com/api/...") Лёгкий JSON, тот же домен, без секретов
Достать данные пользователя, БД callTool/MCP Нужна авторизация, логика, безопасный backend
Ходить во внешние API (Stripe…) MCP/сервер Фронт не видит секретов, соблюдаем политики

В текущем модуле нам важно научиться сознательно выбирать инструмент. Нужно уйти от мышления «виджет — это фронтенд, значит всё можно сделать через fetch», к архитектуре «виджет — это управляемый UI‑слой поверх LLM+MCP‑бэкенда».

Insight

Взаимодействие с сервером в ChatGPT App логично делить на два уровня:

  • ChatGPT ↔ MCP-сервер: модель вызывает MCP-инструменты. Каждый tool-call — это запуск или переключение бизнес-сценария (подбор подарков, создание заказа, расчёт стоимости и т.п.). Здесь живёт «тяжёлая» логика, работа с данными, внешние API и авторизация.
  • Виджет ↔ сервер: виджет делает лёгкие fetch()-запросы к своему backend’у и/или дергает те же MCP-инструменты через callTool() уже внутри активного сценария. Это локальные шаги: подзагрузить вспомогательные данные, обновить кусок UI, уточнить состояние.

То есть MCP-tool = запуск/управление бизнес-процессом, а fetch()/callTool() из виджета — мелкие операции внутри уже выбранного сценария, не претендующие на изменение общей «истории» диалога.

8. Небольшое практическое упражнение

Чтобы закрепить тему на практике, можно сделать небольшую фичу в GiftGenius.

Предложенный сценарий:

  1. В списке подарков добавьте кнопку «Перейти к оформлению», которая через openExternal открывает страницу оформления заказа на вашем dev‑сайте.
  2. Над списком подарков отрендерите PopularTags из примера выше, чтобы показать популярные категории. При ошибке загрузки сделайте fallback‑текст и не ломайте весь виджет.
  3. Обратите внимание на UX: в тексте GPT‑ответа или в UI виджета объясните пользователю, что «при нажатии кнопки я открою страницу магазина в новой вкладке».

Эта фича в миниатюре показывает оба канала:

  • openExternal для явной навигации;
  • fetch для маленького публичного API, живущего рядом с вашим App.

9. Типичные ошибки при работе с window.fetch и openExternal

Ошибка №1: пытаться использовать виджет как полноценный SPA‑клиент ко всем вашим API.
Старые привычки сильно тянут в сторону «а давайте просто дернем наш REST/GraphQL прямо из React». В мире ChatGPT Apps это приводит к лобовому столкновению с песочницей: часть запросов тупо не пройдёт, часть будет заблокирована политиками, а безопасность проекта окажется под вопросом. Сложная логика и доступ к данным пользователя должны идти через MCP/инструменты, а не напрямую из виджета.

Ошибка №2: хранить секреты и токены в коде виджета.
Иногда хочется «быстро прототипировать» и вписать в код фронтенда API‑ключ к какому‑нибудь сервису («ну я же только тестирую»). Это плохая идея даже для обычного SPA, а для ChatGPT Apps — категорическое нет. Виджет — публичное окружение; секреты должны жить в конфиге сервера или в системах управления секретами (Vercel env, KMS и т.п.).

Ошибка №3: считать, что fetch к любому домену «просто сработает».
Даже если в Dev Mode какой‑то запрос прошёл (например, потому что туннель проброшен нестандартно), в продакшен‑окружении он почти наверняка сломается: ChatGPT ограничивает исходящие запросы, и произвольный внешний домен виджету недоступен. Ориентируйтесь на то, что виджет надёжно может ходить только к своему домену и к очень маленькому белому списку явно разрешённых ресурсов.

Ошибка №4: использовать window.open вместо openExternal.
Технически иногда window.open может сработать, особенно в браузерном превью, и создаётся иллюзия, что «всё ок». Но в реальном ChatGPT окружении, особенно в нативных клиентах, поведение будет непредсказуемым. Пользователь может вообще не увидеть перехода или получить странную ошибку. Правильный путь — использовать openExternal (через хук useOpenExternal), который знает, как корректно открыть ссылку в текущей среде.

Ошибка №5: не обрабатывать ошибки fetch и не показывать пользователю состояние загрузки.
В песочнице сетевые ошибки — не исключение, а норма: туннель может упасть, домен может смениться, политики могут что‑то отрезать. Если вы просто делаете await fetch(...) и дальше рендерите UI, предполагая, что данные есть, — получите странный полусломанный интерфейс, который «иногда работает, иногда нет». Всегда ставьте try/catch, проверяйте res.ok, показывайте «Загружаю…» и аккуратное сообщение об ошибке.

Ошибка №6: превращать openExternal в скрытый редирект.
Иногда встречается желание по клику на любую кнопку сразу уводить пользователя на внешний сайт, особенно на checkout, без какого‑либо контекста в тексте. Это выглядит странно и для пользователя, и для ревьюеров Store. Хороший тон — в явном виде писать, что сейчас произойдёт: либо GPT‑модель сообщает «Я открою страницу магазина…», либо сама кнопка подписана достаточно прозрачно («Перейти к оплате на сайте магазина»).

Ошибка №7: забывать, что виджет — не единственный «хозяин» диалога.
Если ваш UI пытается навязать пользователю сложный сценарий с кучей своих ссылок и сетевых запросов, игнорируя сам чат и follow‑up’ы, получается хуже UX и хуже качество работы модели. Вспоминайте архитектуру: GPT решает, когда показать App, как использовать его результаты, а виджет лишь подсказывает и визуализирует. Навигацию и сетевые вызовы нужно проектировать так, чтобы они вписывались в общий диалог, а не перетягивали всё одеяло на себя.

1
Задача
ChatGPT Apps, 3 уровень, 4 лекция
Недоступна
CTA-кнопки для внешней навигации через openExternal
CTA-кнопки для внешней навигации через openExternal
1
Задача
ChatGPT Apps, 3 уровень, 4 лекция
Недоступна
Публичный API-роут + абсолютный fetch из виджета
Публичный API-роут + абсолютный fetch из виджета
1
Опрос
Виджет (Apps SDK), 3 уровень, 4 лекция
Недоступен
Виджет (Apps SDK)
Виджет (Apps SDK): состояние, UI и песочница
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ