diff --git a/Readme.md b/Readme.md index f3f74bb69..f0fc7b006 100644 --- a/Readme.md +++ b/Readme.md @@ -6,3 +6,4 @@ This monorepo contains the code that powers the generated JavaScript & TypeScrip - [`@gadgetinc/react`](https://github.com/gadget-inc/js-clients/tree/main/packages/react) contains bindings for React applications which want to use their gadget backend in React components - [`@gadgetinc/react-shopify-app-bridge`](https://github.com/gadget-inc/js-clients/tree/main/packages/react-shopify-app-bridge) contains React components for building Shopify Applications using Shopify's App Bridge and Gadget's Shopify Connection. Read more in the [Gadget docs](https://docs.gadget.dev/guides/connections/shopify). - [`@gadgetinc/shopify-extensions`](https://github.com/gadget-inc/js-clients/tree/main/packages/shopify-extensions) contains utilities for working with [Shopify UI extensions](https://github.com/Shopify/ui-extensions) in both React and javascript. +- [`@gadgetinc/react-chatgpt-apps`](https://github.com/gadget-inc/js-clients/tree/main/packages/react-chatgpt-apps) contains utilities building [ChatGPT Apps SDK](https://developers.openai.com/apps-sdk/) widgets in React. diff --git a/jest.config.js b/jest.config.js index 283e26b22..4a57765f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,5 +7,6 @@ module.exports = { "/packages/tiny-graphql-query-compiler/jest.config.js", "/packages/shopify-extensions/jest.config.js", "/packages/react-bigcommerce/jest.config.js", + "/packages/react-chatgpt-apps/jest.config.js", ], }; diff --git a/packages/react-chatgpt-apps/CHANGELOG.md b/packages/react-chatgpt-apps/CHANGELOG.md new file mode 100644 index 000000000..f8c7244df --- /dev/null +++ b/packages/react-chatgpt-apps/CHANGELOG.md @@ -0,0 +1 @@ +# @gadgetinc/react-chatgpt-apps diff --git a/packages/react-chatgpt-apps/README.md b/packages/react-chatgpt-apps/README.md new file mode 100644 index 000000000..7c959355c --- /dev/null +++ b/packages/react-chatgpt-apps/README.md @@ -0,0 +1,193 @@ +
+

+ Gadget logo +

+

+ + GitHub CI status + + + npm version + +

+

+ + React components for building ChatGPT Apps powered by Gadget backends. + +

+
+ +`@gadgetinc/react-chatgpt-apps` is a React library for connecting ChatGPT Apps to Gadget backend applications. It provides: + +1. A `` component that automatically authenticates your ChatGPT App with your Gadget backend +2. Easy integration with `@gadgetinc/react` hooks for reading and writing data +3. Automatic token management using OpenAI's authentication system + +When building a ChatGPT App that needs to interact with a Gadget backend, this library handles all the authentication complexity for you, allowing your React components to focus on building great user experiences. + +## Installation + +`@gadgetinc/react-chatgpt-apps` is a companion package to your Gadget app's JavaScript client and `@gadgetinc/react`, so you need to install all three packages. + +First, set up the Gadget NPM registry and install your app's client: + +```bash +npm config set @gadget-client:registry https://registry.gadget.dev/npm + +# then install your app's client +npm install @gadget-client/your-chatgpt-app-slug +``` + +Full installation instructions for your app's client can be found in the Gadget docs at `https://docs.gadget.dev/api//external-api-calls/installing`. + +Then, install the React libraries: + +```bash +npm install @gadgetinc/react @gadgetinc/react-chatgpt-apps react +# or +yarn add @gadgetinc/react @gadgetinc/react-chatgpt-apps react +``` + +## Setup + +To use this library, wrap your ChatGPT App's React components in the `Provider` component from this package. The `Provider` automatically handles authentication with your Gadget backend using OpenAI's authentication system. + +```tsx +import { Client } from "@gadget-client/your-chatgpt-app-slug"; +import { Provider } from "@gadgetinc/react-chatgpt-apps"; + +// instantiate the API client for your Gadget app +const api = new Client(); + +export function App() { + return ( + + + + ); +} +``` + +That's it! The `Provider` component will: + +1. Automatically fetch an authentication token from OpenAI when your app loads +2. Configure your Gadget API client to use this token for all requests +3. Ensure all API calls wait for authentication to be ready before proceeding + +## Example usage + +Once you've wrapped your app in the `Provider`, you can use all the hooks from `@gadgetinc/react` to interact with your Gadget backend: + +```tsx +import { Client } from "@gadget-client/my-chatgpt-app"; +import { useAction, useFindMany } from "@gadgetinc/react"; +import { Provider } from "@gadgetinc/react-chatgpt-apps"; + +const api = new Client(); + +export function App() { + return ( + + + + ); +} + +function TaskList() { + // Fetch tasks from your Gadget backend - authentication is handled automatically + const [{ data: tasks, fetching, error }] = useFindMany(api.task, { + select: { + id: true, + title: true, + completed: true, + }, + }); + + // Set up an action to mark tasks as complete + const [_, completeTask] = useAction(api.task.complete); + + if (fetching) return
Loading tasks...
; + if (error) return
Error: {error.message}
; + + return ( +
    + {tasks.map((task) => ( +
  • + completeTask({ id: task.id })} /> + {task.title} +
  • + ))} +
+ ); +} +``` + +## How it works + +ChatGPT Apps use a special authentication mechanism provided by OpenAI. When your app loads in ChatGPT, it can request an authentication token from OpenAI that identifies the current user and conversation. This library: + +1. Calls OpenAI's `callTool` function with the special `__getGadgetAuthTokenV1` tool to retrieve an authentication token +2. Configures your Gadget API client to include this token in all HTTP requests as a `Bearer` token +3. Ensures that any API calls made before the token is fetched will wait for authentication to be ready + +This all happens automatically when you wrap your app in the `Provider` component. Your Gadget backend will receive the authenticated requests and can use the token to identify the user and enforce permissions. + +## API Documentation + +### `` + +The `Provider` component must wrap your ChatGPT App to enable authenticated communication with your Gadget backend. + +**Props:** + +- `api` (required): An instance of your Gadget application's API client. Example: `new Client()` where `Client` is imported from `@gadget-client/your-app-slug`. +- `children` (required): Your React components that will use the Gadget API. + +**Example:** + +```tsx +import { Client } from "@gadget-client/my-chatgpt-app"; +import { Provider } from "@gadgetinc/react-chatgpt-apps"; + +const api = new Client(); + +export function App() { + return ( + + + + ); +} +``` + +The `Provider` component: + +- Automatically fetches an authentication token from OpenAI when mounted +- Configures your API client to use this token for all requests +- Handles token management transparently - you don't need to manually pass tokens around +- Ensures all API calls wait for authentication to be ready + +### Using with `@gadgetinc/react` hooks + +Once your app is wrapped in the `Provider`, you can use all the hooks from `@gadgetinc/react` to interact with your Gadget backend. All requests will automatically include the authentication token. + +See the [`@gadgetinc/react` documentation](https://github.com/gadget-inc/js-clients/blob/main/packages/react/README.md) for the full list of available hooks including: + +- `useFindOne` - Fetch a single record by ID +- `useFindMany` - Fetch a list of records with filtering, sorting, and pagination +- `useAction` - Run actions on your Gadget models +- `useGlobalAction` - Run global actions +- `useFetch` - Make custom HTTP requests to your Gadget backend + +All of these hooks will work seamlessly with the ChatGPT Apps authentication provided by this package. + +## Authentication Flow + +When your ChatGPT App loads, the following happens automatically: + +1. The `Provider` component calls OpenAI's `callTool` function with the `__getGadgetAuthTokenV1` tool name +2. OpenAI returns an authentication token specific to the current user and conversation +3. The `Provider` configures your Gadget API client to include this token as a `Bearer` token in the `Authorization` header of all HTTP requests +4. Your Gadget backend receives the token and can use it to identify the user and enforce permissions + +If the token fetch fails (for example, if the app is not running in a ChatGPT environment), an error will be thrown. This ensures your app doesn't make unauthenticated requests by mistake. diff --git a/packages/react-chatgpt-apps/jest.config.js b/packages/react-chatgpt-apps/jest.config.js new file mode 100644 index 000000000..a4a483b52 --- /dev/null +++ b/packages/react-chatgpt-apps/jest.config.js @@ -0,0 +1,186 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +export default { + displayName: "react-chatgpt-apps", + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: process.env.LAYERCI ? "/tmp/jest-cache" : undefined, + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + // coverageDirectory: undefined, + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + extensionsToTreatAsEsm: [".ts", ".tsx"], + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: "/../api/spec/jest.globalsetup.ts", + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: "ts-jest", + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state between every test + restoreMocks: true, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + roots: [""], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: ["./spec/setup.ts"], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: ["/spec/jest.setup.ts"], + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "setup-polly-jest/jest-environment-jsdom", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [path.join(__dirname, "spec/(*.)+(spec|test).[tj]s?(x)")], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + testPathIgnorePatterns: ["/node_modules/"], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + testRunner: "jest-circus/runner", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: undefined, + transform: { "^.+\\.(t|j)sx?$": ["@swc/jest"] }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/" + // ], + // transformIgnorePatterns: ["/node_modules/(?!lodash)"], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/packages/react-chatgpt-apps/package.json b/packages/react-chatgpt-apps/package.json new file mode 100644 index 000000000..cfcea0e62 --- /dev/null +++ b/packages/react-chatgpt-apps/package.json @@ -0,0 +1,46 @@ +{ + "name": "@gadgetinc/react-chatgpt-apps", + "version": "0.1.0", + "files": [ + "README.md", + "dist/**/*" + ], + "license": "MIT", + "repository": "github:gadget-inc/js-clients", + "homepage": "https://github.com/gadget-inc/js-clients/tree/main/packages/react-chatgpt-apps", + "type": "module", + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.js" + }, + "./package.json": "./package.json" + }, + "source": "src/index.ts", + "main": "dist/cjs/index.js", + "scripts": { + "typecheck": "tsc --noEmit", + "build": "rm -rf dist && tsc -b tsconfig.cjs.json tsconfig.esm.json && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", + "watch": "tsc --watch --preserveWatchOutput", + "prepublishOnly": "pnpm build", + "prerelease": "gitpkg publish" + }, + "dependencies": { + "@gadgetinc/api-client-core": "^0.15.46" + }, + "devDependencies": { + "@gadgetinc/api-client-core": "workspace:*", + "@gadgetinc/react": "workspace:*", + "@types/react": "^19.1.1", + "@types/react-dom": "^19.1.1", + "conditional-type-checks": "^1.0.6", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@gadgetinc/react": "^0.22.0" + } +} diff --git a/packages/react-chatgpt-apps/spec/Provider.spec.tsx b/packages/react-chatgpt-apps/spec/Provider.spec.tsx new file mode 100644 index 000000000..a609e1ff5 --- /dev/null +++ b/packages/react-chatgpt-apps/spec/Provider.spec.tsx @@ -0,0 +1,258 @@ +import { Client } from "@gadget-client/related-products-example"; +import { AuthenticationMode, type AnyClient } from "@gadgetinc/api-client-core"; +import { jest } from "@jest/globals"; +import "@testing-library/jest-dom"; +import { render, waitFor } from "@testing-library/react"; +import React, { useEffect, useState } from "react"; +import { Provider, getToken } from "../src/Provider.js"; + +describe("ChatGPT Apps Provider", () => { + let api: AnyClient; + let mockFetch: jest.Mock; + const mockCallTool = jest.fn<() => Promise<{ structuredContent: { token: string } | { token: null; error: string } }>>(); + + beforeAll(() => { + (window as any).openai = { + callTool: mockCallTool, + }; + }); + + beforeEach(() => { + mockFetch = jest.fn(); + api = new Client({ + fetchImplementation: mockFetch, + }); + + // Default mock response for token fetch + mockCallTool.mockResolvedValue({ + structuredContent: { token: "test-auth-token-123" }, + }); + }); + + afterEach(() => { + mockCallTool.mockReset(); + mockFetch.mockReset(); + }); + + afterAll(() => { + delete (window as any).openai; + }); + + const ChildComponent = () => { + const [ready, setReady] = useState(false); + + useEffect(() => { + // Wait a bit for provider to set up auth + setTimeout(() => setReady(true), 100); + }, []); + + useEffect(() => { + if (!ready) return; + void api.connection.fetch("/test-endpoint"); + }, [ready]); + + return Hello world; + }; + + describe("getToken", () => { + test("successfully retrieves token from OpenAI tool", async () => { + mockCallTool.mockResolvedValueOnce({ + structuredContent: { token: "my-gadget-token" }, + }); + + const token = await getToken(); + + expect(mockCallTool).toHaveBeenCalledWith("__getGadgetAuthTokenV1", {}); + expect(token).toBe("my-gadget-token"); + }); + + test("throws error when token fetch fails", async () => { + mockCallTool.mockResolvedValueOnce({ + structuredContent: { token: null, error: "Authentication failed" }, + }); + + await expect(getToken()).rejects.toThrow("Authentication failed"); + }); + }); + + describe("Provider component", () => { + test("renders children immediately", () => { + const { container } = render( + + hello world + + ); + + expect(container.outerHTML).toMatchInlineSnapshot(`"
hello world
"`); + }); + + test("sets custom authentication mode on mount", async () => { + expect(api.connection.authenticationMode).not.toEqual(AuthenticationMode.Custom); + + render( + + test + + ); + + await waitFor(() => { + expect(api.connection.authenticationMode).toEqual(AuthenticationMode.Custom); + }); + }); + + test("fetches token and adds Bearer token to fetch requests", async () => { + render( + + + + ); + + // Wait for token to be fetched + await waitFor(() => { + expect(mockCallTool).toHaveBeenCalledWith("__getGadgetAuthTokenV1", {}); + }); + + // Wait for fetch to be called with auth header + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/test-endpoint"), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer test-auth-token-123", + }), + }) + ); + }); + }); + + test("fetch requests wait for token to resolve before proceeding (regression)", async () => { + // Create a controlled promise for the token fetch + let resolveToken: (value: { structuredContent: { token: string } }) => void; + const tokenPromise = new Promise<{ structuredContent: { token: string } }>((resolve) => { + resolveToken = resolve; + }); + + mockCallTool.mockReturnValueOnce(tokenPromise); + + render( + + + + ); + + // Wait for setup + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Fetch should not have been called yet (waiting for token) + expect(mockFetch).not.toHaveBeenCalled(); + + // Now resolve the token + resolveToken!({ structuredContent: { token: "delayed-token" } }); + + // Wait for fetch to be called with the delayed token + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/test-endpoint"), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer delayed-token", + }), + }) + ); + }); + }); + + test("multiple concurrent requests use the same token", async () => { + let resolveToken: (value: { structuredContent: { token: string } }) => void; + const tokenPromise = new Promise<{ structuredContent: { token: string } }>((resolve) => { + resolveToken = resolve; + }); + + mockCallTool.mockReturnValueOnce(tokenPromise); + + const MultiRequestComponent = () => { + const [ready, setReady] = useState(false); + + useEffect(() => { + setTimeout(() => setReady(true), 100); + }, []); + + useEffect(() => { + if (!ready) return; + void api.connection.fetch("/endpoint-1"); + void api.connection.fetch("/endpoint-2"); + }, [ready]); + + return test; + }; + + render( + + + + ); + + // Wait for setup + await new Promise((resolve) => setTimeout(resolve, 150)); + + // Fetches should not have been called yet + expect(mockFetch).not.toHaveBeenCalled(); + + // Token should only be fetched once + expect(mockCallTool).toHaveBeenCalledTimes(1); + + // Now resolve the token + resolveToken!({ structuredContent: { token: "shared-token" } }); + + // Wait for both fetches to complete + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + // Both should have the same token + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/endpoint-1"), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer shared-token", + }), + }) + ); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/endpoint-2"), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer shared-token", + }), + }) + ); + }); + + test("subsequent renders don't refetch the token", async () => { + const { rerender } = render( + + version 1 + + ); + + // Wait for initial token fetch + await waitFor(() => { + expect(mockCallTool).toHaveBeenCalledTimes(1); + }); + + // Rerender with different children + rerender( + + version 2 + + ); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Token should still only have been fetched once + expect(mockCallTool).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/react-chatgpt-apps/spec/are-the-types-wrong.spec.ts b/packages/react-chatgpt-apps/spec/are-the-types-wrong.spec.ts new file mode 100644 index 000000000..91dd4694d --- /dev/null +++ b/packages/react-chatgpt-apps/spec/are-the-types-wrong.spec.ts @@ -0,0 +1,11 @@ +import execa from "execa"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe("package.json types exports", () => { + it("should have the correct types exports", async () => { + await execa("pnpm", ["exec", "attw", "--pack", "."], { cwd: path.resolve(__dirname, "..") }); + }); +}); diff --git a/packages/react-chatgpt-apps/src/Provider.tsx b/packages/react-chatgpt-apps/src/Provider.tsx new file mode 100644 index 000000000..d8b12ae98 --- /dev/null +++ b/packages/react-chatgpt-apps/src/Provider.tsx @@ -0,0 +1,49 @@ +import type { AnyClient } from "@gadgetinc/api-client-core"; +import { Provider as GadgetUrqlProvider } from "@gadgetinc/react"; +import type { ReactNode } from "react"; +import React, { useEffect, useState } from "react"; + +/** Get the backend auth token for the current widget using the special __getGadgetAuthTokenV1 backend tool */ +export const getToken = async () => { + const result = await window.openai.callTool("__getGadgetAuthTokenV1", {}); + const structuredContent = result.structuredContent as { token: string } | { token: null; error: string }; + if ("error" in structuredContent) { + throw new Error(structuredContent.error); + } + return structuredContent.token; +}; + +/** + * React Provider that ChatGPT Apps Widgets should be wrapped in to make calls to the Gadget backend. + */ +export const Provider = ({ children, api }: { children: ReactNode; api: AnyClient }) => { + // eslint-disable-next-line prefer-const + let [tokenPromise, setTokenPromise] = useState | null>(null); + + useEffect(() => { + if (!tokenPromise) { + tokenPromise = getToken(); + setTokenPromise(tokenPromise); + } + + api.connection.setAuthenticationMode({ + custom: { + async processFetch(_input, init) { + const token = await tokenPromise; + const headers = new Headers(init.headers); + headers.append("Authorization", `Bearer ${token}`); + init.headers ??= {}; + headers.forEach(function (value, key) { + (init.headers as Record)[key] = value; + }); + }, + async processTransactionConnectionParams(params) { + const token = await tokenPromise; + params.auth = { type: "custom", jwt: token }; + }, + }, + }); + }, [api, tokenPromise]); + + return {children}; +}; diff --git a/packages/react-chatgpt-apps/src/index.ts b/packages/react-chatgpt-apps/src/index.ts new file mode 100644 index 000000000..ee3ab8cbd --- /dev/null +++ b/packages/react-chatgpt-apps/src/index.ts @@ -0,0 +1,102 @@ +export * from "./Provider.js"; + +// types from https://github.com/openai/openai-apps-sdk-examples/blob/bebecf5cf2205c3ab1949edec54197ae0cc1613e/src/types.ts +export type OpenAiGlobals< + ToolInput = UnknownObject, + ToolOutput = UnknownObject, + ToolResponseMetadata = UnknownObject, + WidgetState = UnknownObject +> = { + // visuals + theme: Theme; + + userAgent: UserAgent; + locale: string; + + // layout + maxHeight: number; + displayMode: DisplayMode; + safeArea: SafeArea; + + // state + toolInput: ToolInput; + toolOutput: ToolOutput | null; + toolResponseMetadata: ToolResponseMetadata | null; + widgetState: WidgetState | null; + setWidgetState: (state: WidgetState) => Promise; +}; + +// currently copied from types.ts in chatgpt/web-sandbox. +// Will eventually use a public package. +type API = { + callTool: CallTool; + sendFollowUpMessage: (args: { prompt: string }) => Promise; + openExternal(payload: { href: string }): void; + + // Layout controls + requestDisplayMode: RequestDisplayMode; +}; + +export type UnknownObject = Record; + +export type Theme = "light" | "dark"; + +export type SafeAreaInsets = { + top: number; + bottom: number; + left: number; + right: number; +}; + +export type SafeArea = { + insets: SafeAreaInsets; +}; + +export type DeviceType = "mobile" | "tablet" | "desktop" | "unknown"; + +export type UserAgent = { + device: { type: DeviceType }; + capabilities: { + hover: boolean; + touch: boolean; + }; +}; + +/** Display mode */ +export type DisplayMode = "pip" | "inline" | "fullscreen"; +export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{ + /** + * The granted display mode. The host may reject the request. + * For mobile, PiP is always coerced to fullscreen. + */ + mode: DisplayMode; +}>; + +export type CallToolResponse = { + result: string; + structuredContent: unknown; +}; + +/** Calling APIs */ +export type CallTool = (name: string, args: Record) => Promise; + +/** Extra events */ +export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals"; +export class SetGlobalsEvent extends CustomEvent<{ + globals: Partial; +}> { + readonly type = SET_GLOBALS_EVENT_TYPE; +} + +/** + * Global oai object injected by the web sandbox for communicating with chatgpt host page. + */ +declare global { + interface Window { + openai: API & OpenAiGlobals; + } + + interface WindowEventMap { + [SET_GLOBALS_EVENT_TYPE]: SetGlobalsEvent; + } +} diff --git a/packages/react-chatgpt-apps/tsconfig.base.json b/packages/react-chatgpt-apps/tsconfig.base.json new file mode 100644 index 000000000..37dd6956f --- /dev/null +++ b/packages/react-chatgpt-apps/tsconfig.base.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["es2020", "DOM"], + "jsx": "react", + "baseUrl": "./", + "target": "es2020", + "types": ["jest", "node"], + "importHelpers": true + } +} diff --git a/packages/react-chatgpt-apps/tsconfig.cjs.json b/packages/react-chatgpt-apps/tsconfig.cjs.json new file mode 100644 index 000000000..25c2e42ab --- /dev/null +++ b/packages/react-chatgpt-apps/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/cjs", + "module": "CommonJS", + "moduleResolution": "node" + }, + "include": ["./src"] +} diff --git a/packages/react-chatgpt-apps/tsconfig.esm.json b/packages/react-chatgpt-apps/tsconfig.esm.json new file mode 100644 index 000000000..e52f7056b --- /dev/null +++ b/packages/react-chatgpt-apps/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/esm", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": ["./src"] +} diff --git a/packages/react-chatgpt-apps/tsconfig.json b/packages/react-chatgpt-apps/tsconfig.json new file mode 100644 index 000000000..abadc1a9c --- /dev/null +++ b/packages/react-chatgpt-apps/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["es2020", "DOM"], + "jsx": "react", + "baseUrl": "./", + "moduleResolution": "nodenext", + "module": "nodenext", + "target": "es2020", + "types": ["jest", "node"], + "outDir": "./dist" + }, + "include": ["./src", "./spec"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6026240a..157f1e8af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,31 @@ importers: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + packages/react-chatgpt-apps: + dependencies: + '@gadgetinc/api-client-core': + specifier: workspace:* + version: link:../api-client-core + devDependencies: + '@gadgetinc/react': + specifier: workspace:* + version: link:../react + '@types/react': + specifier: ^19.1.1 + version: 19.1.10 + '@types/react-dom': + specifier: ^19.1.1 + version: 19.1.7(@types/react@19.1.10) + conditional-type-checks: + specifier: ^1.0.6 + version: 1.0.6 + react: + specifier: ^19.1.1 + version: 19.1.1 + react-dom: + specifier: ^19.1.1 + version: 19.1.1(react@19.1.1) + packages/react-shopify-app-bridge: dependencies: '@gadgetinc/api-client-core':