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, поспілкуватися з бекендом. Це робиться через fetch, але можливі суттєві обмеження.
  3. Виклик інструментів MCP: звернення до інструментів (MCP/бекенд), які не мають жодних обмежень.

У цій лекції ми зосередимося на першому й найбезпечнішому шляху (навігації) та обережно познайомимося з контрольованим fetch. У наступних модулях розберемо MCP та інструменти як основний спосіб серйозної взаємодії із сервером.

2. openExternal: безпечний «телепорт» користувача

Чому не можна просто викликати window.open

У вебзастосунку ви зробили б приблизно так:


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

У пісочниці ChatGPT це або не спрацює, або спрацює якось дуже дивно. Віджет — це ізольований iframe із жорстким sandbox, який не має тих самих прав, що й вкладка браузера.

Крім того, хост ChatGPT хоче контролювати, куди й коли ви ведете користувача, щоб:

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

Саме тому створили спеціальний 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 приходять рекомендації з полем url. Додамо до кожної картки кнопку, яка відкриватиме товар на вашому сайті:

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 може бути доступний, але лише для обмеженого списку доменів (зазвичай це ваш домен, на якому працює застосунок, і, можливо, кілька явно дозволених API);
  • запити можуть іти через спеціальний проксі хоста, який фільтрує заголовки та URL;
  • деякі методи (PUT, DELETE) або нестандартні заголовки можуть блокуватися політиками безпеки.

Водночас у вас лишається зручний варіант. Якщо віджет і бекенд живуть на одному домені (як у шаблоні на Next.js, де і MCP‑сервер, і UI обслуговуються одним застосунком), внутрішні запити fetch("/api/...") зазвичай дозволені.

Головне — не розраховувати на те, що віджет зможе ходити на будь‑який API в інтернеті. Усе «важке» спілкування із зовнішніми сервісами (Stripe, Notion, CRM тощо) має відбуватися на боці MCP/бекенду. Саме туди ChatGPT звертається як до довіреного ресурсу.

Інсайт

У віджеті ChatGPT варто одразу відмовитися від відносних шляхів і використовувати абсолютні URL. Причина проста: ваш HTML не працює на тому самому домені, що й бекенд. 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, товстий бекенд

З обмежень fetch і самої пісочниці випливає ширший архітектурний принцип, важливий для всього курсу. Ми вже кілька разів повторювали цю мантру, але тепер саме час її закріпити.

Віджет — це тонкий UI‑шар. Він рендерить те, що вже підготував бекенд (через MCP/tools), показує реакції на дії користувача й у крайньому разі робить кілька невеликих публічних запитів.

Усе, що повʼязано з авторизацією, доступом до персональних даних, секретами та нетривіальною бізнес‑логікою, має жити на боці сервера. Документи з безпеки курсу окремо підкреслюють: фронтенд (React‑віджет) — це «public place», зона нульової довіри, і секретів там бути не повинно.

У матеріалах на цю тему мету формулюють доволі жорстко: остаточно відмовитися від ідеї «товстого клієнта» для ChatGPT Apps. Віджет — лише «голова», а «тіло й мозок» — у MCP/бекенді.

Тому:

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

5. Практика: openExternal у нашому GiftGenius

Давайте трохи охайніше вбудуємо openExternal у наш навчальний застосунок і водночас подумаємо про 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 тут приблизна — у своєму застосунку ви підлаштуєте її під свій бекенд.

6. Практика: акуратний fetch до нашого бекенду

Тепер розберімося з 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: на той самий домен, де працює ваш застосунок. Саме його ви передаєте через тунель і реєструєте в 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 Явний перехід користувача, контрольований хостом
Отримати публічні дані із застосунку fetch("https://my.com/api/...") Легкий JSON, той самий домен, без секретів
Отримати дані користувача, БД callTool/MCP Потрібна авторизація, логіка, безпечний бекенд
Звертатися до зовнішніх API (Stripe…) MCP/сервер Фронтенд не бачить секретів, дотримуємося політик

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

Інсайт

Взаємодію із сервером у ChatGPT Apps логічно ділити на два рівні:

  • ChatGPT ↔ MCP‑сервер: модель викликає MCP‑інструменти. Кожен tool‑call — це запуск або перемикання бізнес‑сценарію (підбір подарунків, створення замовлення, розрахунок вартості тощо). Тут живе «важка» логіка, робота з даними, зовнішні API та авторизація.
  • Віджет ↔ сервер: віджет робить легкі fetch()‑запити до свого бекенду та/або викликає ті самі MCP‑інструменти через callTool() уже всередині активного сценарію. Це локальні кроки: дозавантажити допоміжні дані, оновити шматок UI, уточнити стан.

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

8. Невелика практична вправа

Щоб закріпити тему на практиці, можна додати невелику можливість у GiftGenius.

Запропонований сценарій:

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

Ця можливість у мініатюрі показує обидва канали:

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

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 намагається навʼязати користувачеві складний сценарій із купою своїх посилань і мережевих запитів, ігноруючи сам чат і подальші уточнювальні запитання, виходить гірший UX і гірша якість роботи моделі. Памʼятайте архітектуру: GPT вирішує, коли показати застосунок і як використати його результати, а віджет лише підказує та візуалізує. Навігацію й мережеві виклики потрібно проєктувати так, щоб вони вписувалися в загальний діалог, а не «перетягували ковдру» на себе.

1
Опитування
Віджет (Apps SDK), рівень 3, лекція 4
Недоступний
Віджет (Apps SDK)
Віджет (Apps SDK): стан, UI та пісочниця
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ