CodeGym /Kursy /ChatGPT Apps /Interakcja z serwerem: window.fetch i openExternal

Interakcja z serwerem: window.fetch i openExternal

ChatGPT Apps
Poziom 3 , Lekcja 4
Dostępny

1. Dwie drogi na zewnątrz: nawigacja i dane

Jeśli typowy deweloper Next.js słyszy „trzeba pójść na serwer”, ręka odruchowo sięga po fetch albo po ulubionego klienta HTTP. W świecie ChatGPT Apps taki odruch prowadzi do bólu.

W części kursu poświęconej bezpieczeństwu widżetów w ChatGPT Apps proponujemy od początku przełamać ten stary odruch. Widżet nie żyje w swobodnym internecie: siedzi w twardej izolacji, a dostęp sieciowy jest filtrowany i ograniczany politykami hosta.

Widżet ma tylko trzy podstawowe okna na zewnątrz:

  1. Nawigacja: wysłać użytkownika gdzieś do świata zewnętrznego. Do tego służy openExternal.
  2. Wymiana danych: pobrać/wysłać JSON, porozmawiać z backendem. Odbywa się to przez fetch, ale z istotnymi ograniczeniami.
  3. MCP tool call: wywołanie narzędzi (MCP / backend), które nie podlegają tym ograniczeniom.

W tej lekcji skupiamy się na pierwszej i najbezpieczniejszej drodze (nawigacja) i ostrożnie poznajemy kontrolowany fetch. W kolejnych modułach omówimy MCP i narzędzia jako główny sposób poważnej komunikacji z serwerem.

2. openExternal: bezpieczny „teleport” użytkownika

Dlaczego nie można po prostu zrobić window.open

W typowej aplikacji webowej zrobilibyście mniej więcej tak:


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

W piaskownicy ChatGPT to albo nie zadziała, albo zadziała bardzo dziwnie. Widżet — to odizolowany iframe z twardym sandbox, który nie ma tych samych uprawnień co karta przeglądarki.

Ponadto host ChatGPT chce kontrolować, dokąd i kiedy prowadzicie użytkownika, aby:

  • nie dopuścić do ukrytego śledzenia;
  • pokazać użytkownikowi zrozumiały interfejs potwierdzenia (zwłaszcza w klientach mobilnych/desktopowych);
  • zapewnić spójne zachowanie linków w różnych środowiskach (web, desktop, aplikacja mobilna).

Dlatego powstało specjalne API openExternal, dostępne przez window.openai albo wygodniejszy hook Reacta useOpenExternal.

Jak wygląda useOpenExternal

W oficjalnych przykładach Apps SDK hook useOpenExternal jest zaimplementowany mniej więcej tak:


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;
}

Główna myśl jest prosta. Najpierw próbujemy użyć natywnego mechanizmu ChatGPT (window.openai.openExternal). Jeśli widżet renderuje się poza ChatGPT (na przykład otworzyliście go po prostu w przeglądarce podczas developmentu), grzecznie cofamy się do zwykłego window.open.

W waszej aplikacji ten hook jest już w szablonie (jeśli wzięliście standardowe repozytorium od OpenAI) i należy go używać właśnie tak — a nie grzebać ręcznie w window.openai.

Przykład: przycisk „Zobacz w sklepie” w GiftGenius

Wyobraźmy sobie, że w toolOutput naszego GiftGenius przychodzą rekomendacje z polem productUrl. Dodajmy do każdej karty przycisk, który otworzy towar na waszej stronie:

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>Na razie brak rekomendacji…</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)}>
            Otwórz
          </button>
        </div>
      ))}
    </div>
  );
}

Z punktu widzenia użytkownika: klika przycisk, ChatGPT może pokazać systemowe okienko „Otworzyć zewnętrzną stronę?”, po czym otworzy waszą stronę w nowej karcie lub w domyślnej przeglądarce. Nie przenosicie tam żadnych sekretów, tokenów itp., po prostu wysyłacie człowieka „z czatu na stronę”.

3. window.fetch w piaskownicy: to nie ten fetch, do którego przywykliście

Czego zwykle oczekuje frontendowiec

Zazwyczaj rozumowanie jest takie: „Skoro to przeglądarka, można spokojnie uderzyć pod dowolny URL, który ma ustawiony CORS. W najgorszym razie dostanę błąd, ale spróbować można.”

W ekosystemie ChatGPT Apps to niebezpieczne złudzenie. Piaskownica wokół widżetu to nie „drobna złośliwość”, lecz fundamentalny wymóg bezpieczeństwa: żeby widżet nie mógł śledzić użytkownika, chodzić po dowolnych domenach, skanować sieci lokalnej i w ogóle zachowywać się jak mini‑przeglądarka w przeglądarce.

W tym samym raporcie podkreśla się, że w widżecie w Apps SDK dowolny dostęp sieciowy albo jest nieobecny, albo mocno ograniczony — i to nie bug, lecz świadoma decyzja architektoniczna.

Jak to wygląda w praktyce

W typowym środowisku ChatGPT:

  • fetch może być dostępny, ale tylko do ograniczonej listy domen (zwykle waszej domeny, na której działa App, i ewentualnie kilku wyraźnie dozwolonych API);
  • żądania mogą iść przez specjalny proxy hosta, który filtruje nagłówki i URL;
  • niektóre metody (PUT, DELETE) lub niestandardowe nagłówki mogą być blokowane przez polityki bezpieczeństwa.

Mimo to nadal macie wygodną ścieżkę: jeśli wasz widżet i backend żyją pod tą samą domeną (jak w szablonie na Next.js, gdzie i serwer MCP, i UI obsługuje jedna aplikacja), wewnętrzne żądania fetch("/api/...") zazwyczaj będą dozwolone.

Najważniejsze — nie zakładać, że widżet będzie mógł chodzić do dowolnego API w internecie. Cała „gruba” komunikacja z zewnętrznymi usługami (Stripe, Notion, CRM itp.) powinna odbywać się po stronie MCP/backendu, do którego ChatGPT odwołuje się jako do zaufanego zasobu.

Insight

W widżecie ChatGPT trzeba od razu zapomnieć o ścieżkach względnych i żyć na absolutnych URL. Powód jest prosty: wasz HTML nie działa pod tą samą domeną co backend. ChatGPT pobiera wasz HTML, kładzie go na własnym hoście i renderuje w odizolowanym iframe. Każdy "/api/..." albo "/static/logo.png" nagle zaczyna się rozwiązywać względem domeny ChatGPT, a nie waszej aplikacji — i wszystko się sypie.

<base> tutaj prawie nie ratuje. Empirycznie ustalono, że jeśli widżet nie ma ustawionego widgetCSP, możecie wpisać <base href="https://my-app.dev/">: zasoby podciągną się z waszej domeny, ale skrypty, zgodnie z zasadami piaskownicy, i tak nie będą działały. To działa tylko w Dev Mode.

A gdy tylko ustawicie normalny openai/widgetCSP (a w produkcji i tak będzie to wymagane do review), platforma resetuje <base> i gra się kończy: zasoby i skrypty ładują się tylko z domen dozwolonych w CSP, przy czym już po absolutnych linkach.

Rekomendacja: w widżecie ChatGPT wszystko, co wychodzi na zewnątrz — fetch, obrazki, CSS, wasze strony do openExternal — zawsze budujcie jako pełny URL od bazowej domeny aplikacji, którą kontrolujecie przez config/ENV, a nie przez ścieżki względne i <base>.

4. Architektura: cienki UI, gruby backend

Z ograniczeń fetch i ogólnej piaskownicy wynika ważniejsza zasada architektoniczna, kluczowa dla całego kursu. Powtarzaliśmy już tę mantrę, ale teraz pora ją utrwalić: widżet — to cienka warstwa UI. Renderuje to, co backend już przygotował (przez MCP/tools), pokazuje reakcje na działania użytkownika i w ostateczności robi parę drobnych publicznych zapytań.

Wszystko, co związane z autoryzacją, dostępem do danych osobowych, sekretami i niebanalną logiką biznesową, powinno żyć po stronie serwera. Dokumenty bezpieczeństwa kursu wyraźnie podkreślają: frontend (widżet React) — to „public place”, strefa zerowego zaufania, i sekrety nie powinny tam mieszkać.

Wszystkie moje badania w tym temacie formułują cel twardo: „wbić ostatni gwóźdź do trumny idei ‘grubego klienta’” dla ChatGPT Apps. Widżet — tylko głowa, a ciało i mózgi — w MCP/backendzie.

Dlatego:

  • openExternal — do nawigowania użytkownika na waszą „normalną” stronę, gdzie może działać klasyczne SPA, panel użytkownika i reszta;
  • callTool (następny moduł) — główny sposób przekazania modelowi zadania, które zrealizuje wasz backend;
  • fetch z widżetu — rzadki bohater do pomocniczych, bezpiecznych i najlepiej publicznych zapytań do waszej własnej aplikacji.

5. Praktyka: openExternal w naszym GiftGenius

Wbudujmy nieco staranniej openExternal w naszą aplikację szkoleniową i przy okazji pomyślmy o UX.

Mini‑zasada UX

Jeśli prowadzicie użytkownika na zewnątrz, warto:

  • wyraźnie podpowiedzieć, dokąd dokładnie trafi;
  • nie robić niespodziewanych „skoków” bez wyjaśnienia w tekście (albo GPT informuje „Otworzę stronę sklepu…”, albo wy podpisujecie przycisk).

Przykład nagłówka i podpisu:

<button onClick={() => openExternal(gift.url)}>
  Otwórz na stronie sklepu
</button>

Użytkownik rozumie, że za chwilę wyleci z ciepłego czatu do prawdziwego świata z koszykiem i płatnością.

Mały refaktoring komponentu listy

Wcześniej zrobiliśmy już prosty GiftListWidget. Załóżmy, że w poprzednich lekcjach zrealizowaliście widżet, który pokazuje listę prezentów z toolOutput. Teraz zrobimy trochę staranniejszą wersję: dodamy typ Gift z polem url oraz przycisk 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>Na razie nic nie znaleziono. Spróbuj zmienić zapytanie.</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)}>
            Zobacz
          </button>
        </div>
      ))}
    </div>
  );
}

Wciąż nie pracujemy bezpośrednio z window.openai, tylko korzystamy z wygodnego hooka — on już potrafi elegancko spaść do window.open w sytuacjach, gdy środowiska ChatGPT nie ma. Struktura Gift jest tutaj przykładowa — we własnej aplikacji dopasujecie ją do swojego backendu.

6. Praktyka: ostrożny fetch do naszego backendu

Teraz przyjrzyjmy się fetch. Przypomnę jeszcze raz: złożone lub wrażliwe operacje lepiej realizować przez narzędzia/MCP. Ale czasem chce się z widżetu dociągnąć coś lekkiego i publicznego z waszego serwera, na przykład listę popularnych kategorii prezentów.

Prosty publiczny route API w Next.js

Dodajmy do naszego projektu Next.js taki handler:

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

const tags = ["Dla dzieci", "Dla podróżników", "Dla graczy"];

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

Ten route nic nie wie o użytkowniku, nie wymaga tokenów, nie chodzi do zewnętrznych usług — po prostu zwraca statyczną tablicę. Taki kod można bez większego ryzyka przenosić i na produkcję, i do piaskownicy.

Wywołanie tego routa z widżetu przez fetch

Teraz w komponencie widżetu dodamy ładowanie tych tagów. Z uwagi na ograniczenia piaskownicy najwygodniej wykonywać żądanie na absolutny URL: na tę samą domenę, pod którą działa wasza aplikacja — tę, którą przepuszczacie przez tunel i rejestrujecie w Dev Mode ChatGPT (konfigurowaliśmy to w module o Dev Mode i tunelu).

Ważne: domena waszego widżetu będzie czymś w rodzaju https://genius.web-sandbox.oaiusercontent.com, więc nie używajcie ścieżek względnych do ładowania danych, tylko absolutnych. Przykład:

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("Nie udało się załadować popularnych kategorii");
      }
    }

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

  if (error) return <p>{error}</p>;
  if (!tags) return <p>Ładuję popularne kategorie…</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>
  );
}

Ważne, że:

  • ostrożnie obsługujemy błędy i pokazujemy użytkownikowi zrozumiały komunikat;
  • nie zakładamy, że fetch „na pewno zadziała” — polityki piaskownicy mogą w każdej chwili odciąć dostęp, jeśli zmienicie domenę lub zaczniecie robić podejrzane żądania;
  • nie przekazujemy tu żadnych tokenów/sekretów; jeśli będzie potrzebna autentykacja — to już zadanie dla MCP i modułów o Auth.

7. openExternal vs fetch vs narzędzia (callTool): kto za co odpowiada

Żeby się nie mylić, wygodnie mieć w głowie taką „matrycę obowiązków”:

Scenariusz Czego używamy Dlaczego właśnie tak
Otworzyć landing/produkt/panel openExternal Jawne przejście użytkownika, kontrolowane przez hosta
Pobrać publiczne dane z App fetch("my.com/api/...") Lekki JSON, ta sama domena, bez sekretów
Pobrać dane użytkownika, bazę danych callTool/MCP Wymagana autoryzacja, logika, bezpieczny backend
Chodzić do zewnętrznych API (Stripe…) MCP/serwer Front nie widzi sekretów, trzymamy się polityk

W tym module ważne jest nauczyć się świadomie wybierać narzędzie. Trzeba odejść od myślenia „widżet to frontend, więc wszystko można zrobić przez fetch” na rzecz architektury „widżet to zarządzana warstwa UI nad backendem LLM+MCP”.

Insight

Interakcję z serwerem w ChatGPT App sensownie dzielić na dwa poziomy:

  • ChatGPT ↔ serwer MCP: model wywołuje narzędzia MCP. Każde wywołanie narzędzia to uruchomienie lub przełączenie scenariusza biznesowego (dobór prezentów, utworzenie zamówienia, kalkulacja kosztów itp.). Tu żyje „ciężka” logika, praca z danymi, zewnętrzne API i autoryzacja.
  • Widżet ↔ serwer: widżet robi lekkie zapytania fetch() do własnego backendu i/lub wywołuje te same narzędzia MCP przez callTool() już wewnątrz aktywnego scenariusza. To lokalne kroki: dociągnąć pomocnicze dane, odświeżyć kawałek UI, doprecyzować stan.

Czyli MCP‑tool = uruchomienie/zarządzanie procesem biznesowym, a fetch()/callTool() z widżetu — drobne operacje w ramach już wybranego scenariusza, nie roszczące sobie prawa do zmiany ogólnej „historii” dialogu.

8. Małe ćwiczenie praktyczne

Żeby utrwalić temat w praktyce, można dorobić małą funkcję w GiftGenius.

Proponowany scenariusz:

  1. Na liście prezentów dodaj przycisk „Przejdź do zamówienia”, który przez openExternal otwiera stronę składania zamówienia na waszej stronie deweloperskiej.
  2. Nad listą prezentów wyrenderuj PopularTags z powyższego przykładu, aby pokazać popularne kategorie. W razie błędu ładowania zrób tekst zastępczy i nie psuj całego widżetu.
  3. Zwróć uwagę na UX: w tekście odpowiedzi GPT albo w UI widżetu wyjaśnij użytkownikowi, że „po naciśnięciu przycisku otworzę stronę sklepu w nowej karcie”.

Ta funkcja w miniaturze pokazuje oba kanały:

  • openExternal do jawnej nawigacji;
  • fetch do małego publicznego API, żyjącego obok waszej aplikacji.

9. Typowe błędy przy pracy z window.fetch i openExternal

Błąd nr 1: próba używania widżetu jako pełnoprawnego klienta SPA do wszystkich waszych API.
Stare nawyki mocno ciągną w stronę „to może po prostu odpalimy nasz REST/GraphQL prosto z Reacta”. W świecie ChatGPT Apps to kończy się zderzeniem czołowym z piaskownicą: część żądań po prostu nie przejdzie, część zostanie zablokowana przez polityki, a bezpieczeństwo projektu stanie pod znakiem zapytania. Złożona logika i dostęp do danych użytkownika powinny iść przez MCP/narzędzia, a nie bezpośrednio z widżetu.

Błąd nr 2: przechowywanie sekretów i tokenów w kodzie widżetu.
Czasem kusi, żeby „szybko zaprototypować” i wpisać w kod frontendu klucz API do jakiejś usługi („przecież tylko testuję”). To zły pomysł nawet dla zwykłego SPA, a dla ChatGPT Apps — kategoryczne nie. Widżet to środowisko publiczne; sekrety muszą żyć w konfiguracji serwera lub w systemach zarządzania sekretami (Vercel env, KMS itp.).

Błąd nr 3: przekonanie, że fetch do dowolnej domeny „po prostu zadziała”.
Nawet jeśli w Dev Mode jakieś żądanie przeszło (np. dlatego, że tunel był poprowadzony niestandardowo), w środowisku produkcyjnym niemal na pewno się złamie: ChatGPT ogranicza wychodzące żądania i arbitralna zewnętrzna domena jest dla widżetu niedostępna. Zakładajcie, że widżet może niezawodnie chodzić tylko do własnej domeny i bardzo małej białej listy wyraźnie dozwolonych zasobów.

Błąd nr 4: używanie window.open zamiast openExternal.
Technicznie czasem window.open może zadziałać, zwłaszcza w podglądzie przeglądarkowym, i powstaje złudzenie, że „wszystko gra”. Ale w prawdziwym środowisku ChatGPT, zwłaszcza w natywnych klientach, zachowanie będzie nieprzewidywalne. Użytkownik może w ogóle nie zobaczyć przejścia lub dostać dziwny błąd. Właściwa droga — użyć openExternal (przez hook useOpenExternal), który wie, jak poprawnie otworzyć link w bieżącym środowisku.

Błąd nr 5: brak obsługi błędów fetch i niepokazywanie stanu ładowania użytkownikowi.
W piaskownicy błędy sieciowe to nie wyjątek, lecz norma: tunel może się wyłożyć, domena może się zmienić, polityki mogą coś odciąć. Jeśli po prostu robicie await fetch(...) i dalej renderujecie UI, zakładając, że dane są — dostaniecie dziwny, pół‑uszkodzony interfejs, który „czasem działa, a czasem nie”. Zawsze dawajcie try/catch, sprawdzajcie res.ok, pokazujcie „Ładuję…” i elegancki komunikat o błędzie.

Błąd nr 6: zamienianie openExternal w ukryty redirect.
Czasem pojawia się pokusa, by po kliknięciu dowolnego przycisku natychmiast wyprowadzać użytkownika na zewnętrzną stronę, zwłaszcza na checkout, bez jakiegokolwiek kontekstu w tekście. To wygląda dziwnie zarówno dla użytkownika, jak i dla recenzentów Store. Dobry ton — wprost napisać, co zaraz się wydarzy: albo model GPT informuje „Otworzę stronę sklepu…”, albo sam przycisk ma dostatecznie przejrzystą etykietę („Przejdź do płatności na stronie sklepu”).

Błąd nr 7: zapominanie, że widżet nie jest jedynym „gospodarzem” dialogu.
Jeśli wasz UI próbuje narzucić użytkownikowi skomplikowany scenariusz z masą własnych linków i żądań sieciowych, ignorując sam czat i follow‑upy, otrzymacie gorszy UX i gorszą jakość pracy modelu. Pamiętajcie o architekturze: GPT decyduje, kiedy pokazać App, jak użyć jego wyników, a widżet tylko podpowiada i wizualizuje. Nawigację i wywołania sieciowe trzeba projektować tak, by wpisywały się w ogólny dialog, a nie ściągały całą uwagę na siebie.

1
Ankieta/quiz
Widżet (Apps SDK), poziom 3, lekcja 4
Niedostępny
Widżet (Apps SDK)
Widżet (Apps SDK): stan, UI i piaskownica
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION