CodeGym /課程 /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 tool call:呼叫工具(MCP / 後端),不受上述限制。

本講我們聚焦在第一條、也是最安全的途徑(導航),並審慎認識受控的 fetch。在後續模組中,我們會把 MCP 與工具作為與伺服器嚴肅對話的主要方式來講解。

2. openExternal:安全的使用者「傳送門」

為什麼不能直接用 window.open

在一般 Web 應用中,你可能會這樣做:


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

在 ChatGPT 的沙盒中,這要嘛無法運作,要嘛行為非常怪異。小工具是帶有嚴格 sandbox 的隔離 iframe,其權限不同於瀏覽器分頁。

此外,ChatGPT 宿主想要控制你在何時、帶使用者到哪裡,原因包括:

  • 避免隱性追蹤;
  • 向使用者顯示易懂的確認 UI(特別是在行動/桌面客戶端);
  • 確保在不同環境(Web、桌面、行動 App)中連結行為一致。

因此提供了專用 API openExternal,可經由 window.openai 或更方便的 React Hook useOpenExternal 使用。

useOpenExternal 長什麼樣

在官方的 Apps SDK 範例中,Hook 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

在你的應用中,這個 Hook 已包含在樣板裡(若你採用 OpenAI 的標準儲存庫),正確用法就是透過它——而不是直接動手操作 window.openai

範例:GiftGenius 中的「在商店查看」按鈕

假設在我們的 GiftGenius 的 toolOutput 中帶有欄位 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

前端工程師通常的期待

一般邏輯是:「既然這是瀏覽器,就能安心打任何設了 CORS 的 URL。最糟也就報個錯,但總是可以試試看。」

在 ChatGPT Apps 生態中,這是危險的誤解。小工具周遭的沙盒不只是「吹毛求疵」,而是安全的根本要求:避免小工具追蹤使用者、任意打到各種網域、掃描區域網路,或總之像是「瀏覽器中的小瀏覽器」那樣行事。

同一脈絡的報告也強調,Apps SDK 中小工具的任意網路存取要嘛不存在,要嘛被嚴格限制——這不是 bug,而是有意識的架構選擇。

實務上會如何

在典型的 ChatGPT 環境裡:

  • fetch 可能可用,但只能打到有限清單的網域(通常是你的 App 所在網域,或少數明確允許的 API);
  • 請求可能會經由宿主的特殊代理,篩選標頭與 URL;
  • 某些方法(PUTDELETE)或非標準標頭可能被安全政策封鎖。

同時仍有一條便利的路:如果你的小工具與後端在同一網域(像 Next.js 樣板中 MCP 伺服器與 UI 都由同一應用服務),內部請求 fetch("/api/...") 通常會被允許。

重點是——不要指望小工具能打遍網際網路上的任何 API。所有與外部服務(Stripe、Notion、CRM 等)的「厚重」互動,應在 MCP/後端端處理,ChatGPT 會把它們視作受信任資源來呼叫。

Insight

在 ChatGPT 小工具中,請立刻忘記相對路徑,以絕對 URL 為生。原因很簡單:你的 HTML 並非運行在與後端相同的網域。ChatGPT 讀取你的 HTML,將其放在自己的主機上,並在隔離的 iframe 中渲染。任何 "/api/...""/static/logo.png" 都會突然相對於 ChatGPT 的網域解析,而非你的應用——結果一切崩壞。

<base> 幾乎幫不上忙。實驗證明,若小工具未設定widgetCSP,你可以寫上 <base href="https://my-app.dev/">: 資源會從你的網域拉取,但依沙盒規則,腳本依然無法存活。且這只在 Dev Mode 有效。

但只要你設定了正常的 openai/widgetCSP上架審核時在正式環境勢必要設定),平台就會忽略<base>,遊戲結束:資源與腳本僅能從 CSP 允許的網域載入,且必須是絕對連結。

建議:在 ChatGPT 小工具中,所有對外的請求—— fetch、圖片、CSS、為 openExternal 準備的頁面—— 都應基於你掌控的應用基底網域,用完整 URL 建構,透過設定/環境變數配置,而不是仰賴相對路徑與 <base>

4. 架構:薄 UI,厚後端

fetch 的限制與沙盒整體性質,推導出影響整個課程的一項架構原則。我們已多次強調這個箴言,現在正好總結:小工具就是薄薄的 UI 層。它渲染後端(透過 MCP/tools)已準備好的內容,呈現使用者的操作回饋,最多僅做少量小型、公開的請求。

凡牽涉到授權、個資存取、祕密資料與非平凡的商業邏輯,都必須放在伺服器端。課程的安全文件另行強調:前端(React 小工具)是「public place」,零信任區域,祕密不能放在那裡。

我在此主題的所有調研都指向同一目標:為 ChatGPT Apps 的「厚客戶端」概念釘上最後一根釘子。小工具只是頭部,身體與大腦在 MCP/後端。

因此:

  • openExternal——把使用者導到你的「正常」網站,在那裡可運行習慣的 SPA、會員中心等;
  • callTool(下一個模組)——把任務交給模型,使你的後端執行;
  • 小工具中的 fetch——少見的配角,用於你自己的應用中,輕量、安全且最好是公開的請求。

5. 實作:在我們的 GiftGenius 中使用 openExternal

讓我們更謹慎地把 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,而是使用便利的 Hook——它已能在沒有 ChatGPT 環境時回退到 window.open。這裡的 Gift 結構僅做示意——在你的 App 中,會依你的後端調整。

6. 實作:對我們的後端進行審慎的 fetch

現在來談 fetch。再提醒一次:複雜或敏感的操作最好透過工具/MCP 完成。但有時候你會想從小工具向自己的伺服器拉一些輕量且公開的資料,例如熱門禮物分類清單。

在 Next.js 中的簡單公開 API 路由

在我們的 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 所在的同一網域——你透過通道對外公開,並在 ChatGPT 的 Dev Mode 中註冊(我們已在 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 與認證模組的任務。

7. openExternal vs fetch vs 工具(callTool):各自適用情境

為了不搞混,可以在腦中維持這張「職責矩陣」:

情境 該用什麼 為什麼這樣用
開啟著陸頁/商品/個人帳戶 openExternal 由宿主控制的顯式使用者跳轉
從 App 取得公開資料 fetch("my.com/api/...") 輕量 JSON、同一網域、沒有機密
取得使用者資料、資料庫 callTool/MCP 需要授權、邏輯、且安全的後端
呼叫外部 API(Stripe…) MCP/伺服器 前端看不到祕密,並遵守政策

在本模組中,重要的是學會有意識地選擇工具。要從「小工具是前端,所以什麼都用 fetch 搞定」的思維,轉變為「小工具是受控的 UI 層,運行在 LLM+MCP 後端之上」的架構觀。

Insight

在 ChatGPT App 中與伺服器互動,合理切成兩個層次:

  • ChatGPT ↔ MCP 伺服器:模型呼叫 MCP 工具。每一次 tool-call 就是啟動或切換一個商務情境(挑禮物、建立訂單、計算費用等)。「重」邏輯、資料操作、外部 API 與授權都在這裡。
  • 小工具 ↔ 伺服器:小工具向自己的後端發出輕量的 fetch() 請求,且/或在既有情境中透過 callTool() 觸發相同的 MCP 工具。這是局部步驟:補載輔助資料、更新 UI 片段、釐清狀態。

也就是說,MCP-tool = 啟動/管理商務流程,而小工具中的 fetch()/callTool() 則是在既定情境中的小操作,不會改變對話的整體「劇情」。

8. 小型實作練習

為了把概念落地,可以在 GiftGenius 做個小功能。

建議場景:

  1. 在禮物列表中加上「前往結帳」按鈕,透過 openExternal 打開你在開發站上的結帳頁面。
  2. 在禮物列表上方渲染前面範例的 PopularTags,展示熱門分類。若載入失敗,提供 fallback 文案,且不要讓整個小工具壞掉。
  3. 注意 UX:在 GPT 回覆文字或小工具 UI 中向使用者說明「按下按鈕後,我會在新分頁開啟商店頁面」。

這個小功能縮影示範了兩個通道:

  • openExternal 用於明確的導航;
  • fetch 用於與你的 App 並存的小型公開 API。

9. 使用 window.fetch 與 openExternal 的常見錯誤

錯誤 №1:把小工具當成指向你所有 API 的完整 SPA 客戶端。
舊習慣會讓人想「那就直接從 React 打我們的 REST/GraphQL 吧」。在 ChatGPT Apps 的世界,這會直面沙盒:有些請求直接失敗,有些被政策封鎖,專案安全也會受威脅。複雜邏輯與使用者資料存取應透過 MCP/工具進行,而不是由小工具直接呼叫。

錯誤 №2:把祕密和權杖放在小工具的程式碼中。
有時會想「快速做原型」,在前端程式碼硬寫某服務的 API 金鑰(「反正我只是測試」)。這在一般 SPA 就是壞主意,在 ChatGPT Apps 更是絕對不行。小工具是公開環境;祕密應放在伺服器設定或祕密管理系統(Vercel env、KMS 等)。

錯誤 №3:以為對任何網域的 fetch 都會「直接成功」。
即便在 Dev Mode 某個請求看似成功(例如通道設法非標準地轉了),在正式環境幾乎肯定會壞:ChatGPT 會限制外送請求,小工具無法任意打外部網域。把預期建立在:小工具可靠地只能打到自己的網域,以及極小的白名單資源。

錯誤 №4:使用 window.open 代替 openExternal。
技術上在瀏覽器預覽時,window.open 有時會有效,讓人誤以為「一切 OK」。但在真正的 ChatGPT 環境,尤其是原生客戶端中,行為不可預期。使用者可能看不到跳轉,或遇到奇怪錯誤。正確做法是使用 openExternal(透過 useOpenExternal Hook),它知道如何在目前環境中正確開啟連結。

錯誤 №5:不處理 fetch 錯誤,也不向使用者顯示載入狀態。
在沙盒中,網路錯誤不是例外,而是常態:通道可能斷線、網域可能更換、政策可能切掉某些請求。若你只是 await fetch(...),接著就渲染 UI、假設資料已到——你會得到「時好時壞」的半壞介面。務必加上 try/catch、檢查 res.ok、顯示「載入中…」與易懂的錯誤提示。

錯誤 №6:把 openExternal 變成隱性重導。
有人會想在每個按鈕點擊就立刻把使用者帶到外站,尤其是直接跳到結帳,且沒有任何上下文。這對使用者與 Store 審核者都很奇怪。良好作法——明確寫出即將發生的事:要嘛 GPT 模型說「我會開啟商店頁面…」,要嘛按鈕的標示夠透明(例如「前往商店網站完成付款」)。

錯誤 №7:忘了小工具不是對話的唯一「主宰」。
若你的 UI 嘗試強行塞進大量自己的連結與網路請求,忽略聊天本身與後續追問,結果會是更糟的 UX 與更差的模型合作品質。記得架構:GPT 決定何時顯示 App、如何使用其輸出,而小工具只是輔助與視覺化。設計導航與網路呼叫時,要讓它們融入整體對話,而不是喧賓奪主。

1
問卷/小測驗
Widget(Apps SDK),等級 3,課堂 4
未開放
Widget(Apps SDK)
Widget(Apps SDK):狀態、UI 與沙箱
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION