@apollo/client-ai-apps 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/pr.yaml +24 -0
  4. package/.github/workflows/release.yaml +36 -0
  5. package/LICENSE +21 -0
  6. package/dist/apollo_client/client.d.ts +14 -0
  7. package/dist/apollo_client/provider.d.ts +5 -0
  8. package/dist/hooks/useOpenAiGlobal.d.ts +2 -0
  9. package/dist/hooks/useRequestDisplayMode.d.ts +4 -0
  10. package/dist/hooks/useSendFollowUpMessage.d.ts +1 -0
  11. package/dist/hooks/useToolEffect.d.ts +6 -0
  12. package/dist/hooks/useToolInput.d.ts +1 -0
  13. package/dist/hooks/useToolName.d.ts +1 -0
  14. package/dist/index.d.ts +11 -0
  15. package/dist/index.js +177 -0
  16. package/dist/types/application-manifest.d.ts +29 -0
  17. package/dist/types/openai.d.ts +73 -0
  18. package/dist/vite/index.d.ts +1 -0
  19. package/dist/vite/index.js +210 -0
  20. package/dist/vite/operation_manifest_plugin.d.ts +10 -0
  21. package/package.json +53 -0
  22. package/scripts/build-vite.mjs +18 -0
  23. package/scripts/build.mjs +7 -0
  24. package/scripts/dev.mjs +21 -0
  25. package/scripts/shared.mjs +9 -0
  26. package/src/apollo_client/client.test.ts +411 -0
  27. package/src/apollo_client/client.ts +90 -0
  28. package/src/apollo_client/provider.test.tsx +41 -0
  29. package/src/apollo_client/provider.tsx +32 -0
  30. package/src/hooks/useCallTool.test.ts +46 -0
  31. package/src/hooks/useCallTool.ts +8 -0
  32. package/src/hooks/useOpenAiGlobal.test.ts +54 -0
  33. package/src/hooks/useOpenAiGlobal.ts +26 -0
  34. package/src/hooks/useRequestDisplayMode.ts +7 -0
  35. package/src/hooks/useSendFollowUpMessage.ts +7 -0
  36. package/src/hooks/useToolEffect.tsx +41 -0
  37. package/src/hooks/useToolInput.ts +7 -0
  38. package/src/hooks/useToolName.ts +7 -0
  39. package/src/index.ts +12 -0
  40. package/src/types/application-manifest.ts +32 -0
  41. package/src/types/openai.ts +90 -0
  42. package/src/vite/index.ts +1 -0
  43. package/src/vite/operation_manifest_plugin.ts +274 -0
  44. package/vitest-setup.ts +1 -0
  45. package/vitest.config.ts +12 -0
@@ -0,0 +1,46 @@
1
+ import { expect, test, vi } from "vitest";
2
+ import { useCallTool } from "./useCallTool";
3
+
4
+ test("Should execute tool when returned function is called", async () => {
5
+ vi.stubGlobal("openai", {
6
+ callTool: vi.fn(async (name: string, args: Record<string, unknown>) => {
7
+ return {
8
+ structuredContent: {
9
+ data: {
10
+ product: {
11
+ id: "1",
12
+ title: "Pen",
13
+ rating: 5,
14
+ price: 1.0,
15
+ description: "Awesome pen",
16
+ images: [],
17
+ __typename: "Product",
18
+ },
19
+ },
20
+ },
21
+ };
22
+ }),
23
+ });
24
+
25
+ const callTool = useCallTool();
26
+ const result = await callTool("my-tool", { id: 1 });
27
+
28
+ expect(window.openai.callTool).toBeCalledWith("my-tool", { id: 1 });
29
+ expect(result).toMatchInlineSnapshot(`
30
+ {
31
+ "structuredContent": {
32
+ "data": {
33
+ "product": {
34
+ "__typename": "Product",
35
+ "description": "Awesome pen",
36
+ "id": "1",
37
+ "images": [],
38
+ "price": 1,
39
+ "rating": 5,
40
+ "title": "Pen",
41
+ },
42
+ },
43
+ },
44
+ }
45
+ `);
46
+ });
@@ -0,0 +1,8 @@
1
+ type UseCallToolResult = <K>(toolId: string, variables?: Record<string, unknown> | undefined) => Promise<K>;
2
+
3
+ export const useCallTool = (): UseCallToolResult => {
4
+ const callTool = async (toolId: string, variables: Record<string, unknown> | undefined = {}) =>
5
+ await window.openai?.callTool(toolId, variables);
6
+
7
+ return callTool;
8
+ };
@@ -0,0 +1,54 @@
1
+ import { expect, test, vi } from "vitest";
2
+ import { useOpenAiGlobal } from "./useOpenAiGlobal";
3
+ import { renderHook, act } from "@testing-library/react";
4
+ import { SET_GLOBALS_EVENT_TYPE } from "../types/openai";
5
+
6
+ test("Should update value when globals are updated and event it triggered", async () => {
7
+ vi.stubGlobal("openai", {
8
+ toolResponseMetadata: { toolName: "my-tool" },
9
+ });
10
+
11
+ const { result } = renderHook(() => useOpenAiGlobal("toolResponseMetadata"));
12
+ const beforeValue = result.current.toolName;
13
+
14
+ act(() => {
15
+ vi.stubGlobal("openai", {
16
+ toolResponseMetadata: { toolName: "my-other-tool" },
17
+ });
18
+ window.dispatchEvent(
19
+ new CustomEvent(SET_GLOBALS_EVENT_TYPE, {
20
+ detail: { globals: { toolResponseMetadata: { toolName: "my-other-tool" } } },
21
+ })
22
+ );
23
+ });
24
+
25
+ const afterValue = result.current.toolName;
26
+
27
+ expect(beforeValue).toBe("my-tool");
28
+ expect(afterValue).toBe("my-other-tool");
29
+ });
30
+
31
+ test("Should not update value when event key does not match the provided key", async () => {
32
+ vi.stubGlobal("openai", {
33
+ toolResponseMetadata: { toolName: "my-tool" },
34
+ });
35
+
36
+ const { result } = renderHook(() => useOpenAiGlobal("toolResponseMetadata"));
37
+ const beforeValue = result.current.toolName;
38
+
39
+ act(() => {
40
+ vi.stubGlobal("openai", {
41
+ toolResponseMetadata: { toolName: "my-other-tool" },
42
+ });
43
+ window.dispatchEvent(
44
+ new CustomEvent(SET_GLOBALS_EVENT_TYPE, {
45
+ detail: { globals: { toolInput: { id: 1 } } },
46
+ })
47
+ );
48
+ });
49
+
50
+ const afterValue = result.current.toolName;
51
+
52
+ expect(beforeValue).toBe("my-tool");
53
+ expect(afterValue).toBe("my-tool");
54
+ });
@@ -0,0 +1,26 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { SET_GLOBALS_EVENT_TYPE, SetGlobalsEvent, OpenAiGlobals } from "../types/openai";
3
+
4
+ export function useOpenAiGlobal<K extends keyof OpenAiGlobals>(key: K): OpenAiGlobals[K] {
5
+ return useSyncExternalStore(
6
+ (onChange) => {
7
+ const handleSetGlobal = (event: SetGlobalsEvent) => {
8
+ const value = event.detail.globals[key];
9
+ if (value === undefined) {
10
+ return;
11
+ }
12
+
13
+ onChange();
14
+ };
15
+
16
+ window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
17
+ passive: true,
18
+ });
19
+
20
+ return () => {
21
+ window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal);
22
+ };
23
+ },
24
+ () => window.openai[key]
25
+ );
26
+ }
@@ -0,0 +1,7 @@
1
+ import { DisplayMode } from "../types/openai";
2
+
3
+ export const useRequestDisplayMode = () => {
4
+ return async (args: { mode: DisplayMode }) => {
5
+ await window.openai?.requestDisplayMode(args);
6
+ };
7
+ };
@@ -0,0 +1,7 @@
1
+ export const useSendFollowUpMessage = () => {
2
+ return async (prompt: string) => {
3
+ await window.openai?.sendFollowUpMessage({
4
+ prompt,
5
+ });
6
+ };
7
+ };
@@ -0,0 +1,41 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { useToolName } from "./useToolName";
3
+ import { useToolInput } from "./useToolInput";
4
+
5
+ type ToolUseState = {
6
+ appName: string;
7
+ hasNavigated: boolean;
8
+ setHasNavigated: (v: boolean) => void;
9
+ };
10
+
11
+ const ToolUseContext = React.createContext<ToolUseState | null>(null);
12
+
13
+ export function ToolUseProvider({ children, appName }: { children: any; appName: string }) {
14
+ const [hasNavigated, setHasNavigated] = useState(false);
15
+
16
+ return (
17
+ <ToolUseContext.Provider value={{ hasNavigated, setHasNavigated, appName }}>{children}</ToolUseContext.Provider>
18
+ );
19
+ }
20
+
21
+ export const useToolEffect = (
22
+ toolName: string | string[],
23
+ effect: (toolInput: any) => void,
24
+ deps: React.DependencyList = []
25
+ ) => {
26
+ const ctx = React.useContext(ToolUseContext);
27
+ const fullToolName = useToolName();
28
+ const toolInput = useToolInput();
29
+ if (!ctx) throw new Error("useToolEffect must be used within ToolUseProvider");
30
+
31
+ const toolNames = Array.isArray(toolName) ? toolName : [toolName];
32
+
33
+ useEffect(() => {
34
+ const matches = toolNames.some((name) => fullToolName === `${ctx.appName}--${name}`);
35
+
36
+ if (!ctx.hasNavigated && matches) {
37
+ effect(toolInput);
38
+ ctx.setHasNavigated(true);
39
+ }
40
+ }, [ctx.hasNavigated, ctx.setHasNavigated, ctx.appName, toolNames, fullToolName, toolInput, ...deps]);
41
+ };
@@ -0,0 +1,7 @@
1
+ import { useOpenAiGlobal } from "./useOpenAiGlobal";
2
+
3
+ export const useToolInput = () => {
4
+ const toolInput = useOpenAiGlobal("toolInput");
5
+
6
+ return toolInput;
7
+ };
@@ -0,0 +1,7 @@
1
+ import { useOpenAiGlobal } from "./useOpenAiGlobal";
2
+
3
+ export const useToolName = () => {
4
+ const toolResponseMetadata = useOpenAiGlobal("toolResponseMetadata");
5
+
6
+ return toolResponseMetadata?.toolName;
7
+ };
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export * from "./types/openai";
2
+ export * from "./types/application-manifest";
3
+ export * from "./hooks/useOpenAiGlobal";
4
+ export * from "./hooks/useToolName";
5
+ export * from "./hooks/useToolInput";
6
+ export * from "./hooks/useSendFollowUpMessage";
7
+ export * from "./hooks/useRequestDisplayMode";
8
+ export * from "./hooks/useToolEffect";
9
+
10
+ export * from "@apollo/client";
11
+ export { ExtendedApolloClient as ApolloClient } from "./apollo_client/client";
12
+ export { ExtendedApolloProvider as ApolloProvider } from "./apollo_client/provider";
@@ -0,0 +1,32 @@
1
+ export type ApplicationManifest = {
2
+ format: "apollo-ai-app-manifest";
3
+ version: "1";
4
+ name: string;
5
+ description: string;
6
+ hash: string;
7
+ resource: string;
8
+ operations: ManifestOperation[];
9
+ };
10
+
11
+ export type ManifestOperation = {
12
+ id: string;
13
+ name: string;
14
+ type: "query" | "mutation";
15
+ body: string;
16
+ variables: Record<string, string>;
17
+ prefetch: boolean;
18
+ prefetchID?: string;
19
+ tools: ManifestTool[];
20
+ };
21
+
22
+ export type ManifestTool = {
23
+ name: string;
24
+ description: string;
25
+ extraInputs?: ManifestExtraInput[];
26
+ };
27
+
28
+ export type ManifestExtraInput = {
29
+ name: string;
30
+ description: string;
31
+ type: "string" | "boolean" | "number";
32
+ };
@@ -0,0 +1,90 @@
1
+ type UnknownObject = any;
2
+
3
+ declare global {
4
+ interface Window {
5
+ openai: API<any> & OpenAiGlobals;
6
+ }
7
+
8
+ interface WindowEventMap {
9
+ [SET_GLOBALS_EVENT_TYPE]: SetGlobalsEvent;
10
+ }
11
+ }
12
+
13
+ export type OpenAiGlobals<
14
+ ToolInput extends UnknownObject = UnknownObject,
15
+ ToolOutput extends UnknownObject = UnknownObject,
16
+ ToolResponseMetadata extends UnknownObject = UnknownObject,
17
+ WidgetState extends UnknownObject = UnknownObject
18
+ > = {
19
+ theme: Theme;
20
+ userAgent: UserAgent;
21
+ locale: string;
22
+
23
+ // layout
24
+ maxHeight: number;
25
+ displayMode: DisplayMode;
26
+ safeArea: SafeArea;
27
+
28
+ // state
29
+ toolInput: ToolInput;
30
+ toolOutput: ToolOutput | null;
31
+ toolResponseMetadata: ToolResponseMetadata | null;
32
+ widgetState: WidgetState | null;
33
+ };
34
+
35
+ export type API<WidgetState extends UnknownObject> = {
36
+ /** Calls a tool on your MCP. Returns the full response. */
37
+ callTool: (name: string, args: Record<string, unknown>) => Promise<any>;
38
+
39
+ /** Triggers a followup turn in the ChatGPT conversation */
40
+ sendFollowUpMessage: (args: { prompt: string }) => Promise<void>;
41
+
42
+ /** Opens an external link, redirects web page or mobile app */
43
+ openExternal(payload: { href: string }): void;
44
+
45
+ /** For transitioning an app from inline to fullscreen or pip */
46
+ requestDisplayMode: (args: { mode: DisplayMode }) => Promise<{
47
+ /**
48
+ * The granted display mode. The host may reject the request.
49
+ * For mobile, PiP is always coerced to fullscreen.
50
+ */
51
+ mode: DisplayMode;
52
+ }>;
53
+
54
+ setWidgetState: (state: WidgetState) => Promise<void>;
55
+ };
56
+
57
+ // Dispatched when any global changes in the host page
58
+ export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals";
59
+ export class SetGlobalsEvent extends CustomEvent<{
60
+ globals: Partial<OpenAiGlobals>;
61
+ }> {
62
+ readonly type = SET_GLOBALS_EVENT_TYPE;
63
+ }
64
+
65
+ export type CallTool = (name: string, args: Record<string, unknown>) => Promise<any>;
66
+
67
+ export type DisplayMode = "pip" | "inline" | "fullscreen";
68
+
69
+ export type Theme = "light" | "dark";
70
+
71
+ export type SafeAreaInsets = {
72
+ top: number;
73
+ bottom: number;
74
+ left: number;
75
+ right: number;
76
+ };
77
+
78
+ export type SafeArea = {
79
+ insets: SafeAreaInsets;
80
+ };
81
+
82
+ export type DeviceType = "mobile" | "tablet" | "desktop" | "unknown";
83
+
84
+ export type UserAgent = {
85
+ device: { type: DeviceType };
86
+ capabilities: {
87
+ hover: boolean;
88
+ touch: boolean;
89
+ };
90
+ };
@@ -0,0 +1 @@
1
+ export * from "./operation_manifest_plugin";
@@ -0,0 +1,274 @@
1
+ import { readFileSync, writeFileSync } from "fs";
2
+ import { glob } from "glob";
3
+ import { gqlPluckFromCodeStringSync } from "@graphql-tools/graphql-tag-pluck";
4
+ import { createHash } from "crypto";
5
+ import {
6
+ Kind,
7
+ ListTypeNode,
8
+ NamedTypeNode,
9
+ NonNullTypeNode,
10
+ parse,
11
+ print,
12
+ TypeNode,
13
+ ValueNode,
14
+ visit,
15
+ type DocumentNode,
16
+ type OperationDefinitionNode,
17
+ } from "graphql";
18
+ import { ApolloClient, ApolloLink, InMemoryCache } from "@apollo/client";
19
+ import Observable from "rxjs";
20
+ import path from "path";
21
+ import fs from "fs";
22
+
23
+ const root = process.cwd();
24
+
25
+ // TODO: Do we need "validation" of the types for the different properties? Probably?
26
+ const getRawValue = (node: ValueNode): any => {
27
+ switch (node.kind) {
28
+ case Kind.STRING:
29
+ case Kind.BOOLEAN:
30
+ return node.value;
31
+ case Kind.LIST:
32
+ return node.values.map(getRawValue);
33
+ case Kind.OBJECT:
34
+ return node.fields.reduce<Record<string, any>>((acc, field) => {
35
+ acc[field.name.value] = getRawValue(field.value);
36
+ return acc;
37
+ }, {});
38
+ }
39
+ };
40
+
41
+ export function getTypeName(type: TypeNode): string {
42
+ let t = type;
43
+ while (t.kind === "NonNullType" || t.kind === "ListType") {
44
+ t = (t as NonNullTypeNode | ListTypeNode).type;
45
+ }
46
+ return (t as NamedTypeNode).name.value;
47
+ }
48
+
49
+ export const OperationManifestPlugin = () => {
50
+ const cache = new Map();
51
+ let packageJson: any = null;
52
+ let config: any = null;
53
+
54
+ const clientCache = new InMemoryCache();
55
+ const client = new ApolloClient({
56
+ cache: clientCache,
57
+ link: new ApolloLink((operation) => {
58
+ const body = print(removeClientDirective(sortTopLevelDefinitions(operation.query)));
59
+ const name = operation.operationName;
60
+ const variables = (
61
+ operation.query.definitions.find((d) => d.kind === "OperationDefinition") as OperationDefinitionNode
62
+ ).variableDefinitions?.reduce(
63
+ (obj, varDef) => ({ ...obj, [varDef.variable.name.value]: getTypeName(varDef.type) }),
64
+ {}
65
+ );
66
+ const type = (
67
+ operation.query.definitions.find((d) => d.kind === "OperationDefinition") as OperationDefinitionNode
68
+ ).operation;
69
+ const prefetch = (
70
+ operation.query.definitions.find((d) => d.kind === "OperationDefinition") as OperationDefinitionNode
71
+ ).directives?.some((d) => d.name.value === "prefetch");
72
+ const id = createHash("sha256").update(body).digest("hex");
73
+ // TODO: For now, you can only have 1 operation marked as prefetch. In the future, we'll likely support more than 1, and the "prefetchId" will be defined on the `@prefetch` itself as an argument
74
+ const prefetchID = prefetch ? "__anonymous" : undefined;
75
+
76
+ const tools = (
77
+ operation.query.definitions.find((d) => d.kind === "OperationDefinition") as OperationDefinitionNode
78
+ ).directives
79
+ ?.filter((d) => d.name.value === "tool")
80
+ .map((directive) => {
81
+ const directiveArguments: Record<string, any> =
82
+ directive.arguments?.reduce((obj, arg) => ({ ...obj, [arg.name.value]: getRawValue(arg.value) }), {}) ?? {};
83
+ return {
84
+ name: directiveArguments["name"],
85
+ description: directiveArguments["description"],
86
+ extraInputs: directiveArguments["extraInputs"],
87
+ };
88
+ });
89
+
90
+ return Observable.of({ data: { id, name, type, body, variables, prefetch, prefetchID, tools } });
91
+ }),
92
+ });
93
+
94
+ const processFile = async (file: string) => {
95
+ const code = readFileSync(file, "utf-8");
96
+
97
+ if (!code.includes("gql")) return;
98
+
99
+ const fileHash = createHash("md5").update(code).digest("hex");
100
+ if (cache.get("file")?.hash === fileHash) return;
101
+ const sources = await gqlPluckFromCodeStringSync(file, code, {
102
+ modules: [
103
+ { name: "graphql-tag", identifier: "gql" },
104
+ { name: "@apollo/client", identifier: "gql" },
105
+ ],
106
+ }).map((source) => ({
107
+ node: parse(source.body),
108
+ file,
109
+ location: source.locationOffset,
110
+ }));
111
+
112
+ const operations = [];
113
+ for (const source of sources) {
114
+ const type = (source.node.definitions.find((d) => d.kind === "OperationDefinition") as OperationDefinitionNode)
115
+ .operation;
116
+
117
+ let result;
118
+ if (type === "query") {
119
+ result = await client.query({ query: source.node, fetchPolicy: "no-cache" });
120
+ } else if (type === "mutation") {
121
+ result = await client.mutate({ mutation: source.node, fetchPolicy: "no-cache" });
122
+ } else {
123
+ throw new Error("Found an unsupported operation type. Only Query and Mutation are supported.");
124
+ }
125
+ operations.push(result.data);
126
+ }
127
+
128
+ cache.set(file, {
129
+ file: file,
130
+ hash: fileHash,
131
+ operations,
132
+ });
133
+ };
134
+
135
+ const generateManifest = async () => {
136
+ const operations = Array.from(cache.values()).flatMap((entry) => entry.operations);
137
+ if (operations.filter((o) => o.prefetch).length > 1) {
138
+ throw new Error(
139
+ "Found multiple operations marked as `@prefetch`. You can only mark 1 operation with `@prefetch`."
140
+ );
141
+ }
142
+
143
+ let resource = "";
144
+ if (config.command === "serve") {
145
+ resource =
146
+ packageJson.entry?.[config.mode] ??
147
+ `http${config.server.https ? "s" : ""}://${config.server.host ?? "localhost"}:${config.server.port}`;
148
+ } else {
149
+ let entryPoint = packageJson.entry?.[config.mode];
150
+ if (entryPoint) {
151
+ resource = entryPoint;
152
+ } else if (config.mode === "production") {
153
+ resource = "index.html";
154
+ } else {
155
+ throw new Error(
156
+ `No entry point found for mode "${config.mode}". Entry points other than "development" and "production" must be defined in package.json file.`
157
+ );
158
+ }
159
+ }
160
+
161
+ const manifest = {
162
+ format: "apollo-ai-app-manifest",
163
+ version: "1",
164
+ name: packageJson.name,
165
+ description: packageJson.description,
166
+ hash: createHash("sha256").update(Date.now().toString()).digest("hex"),
167
+ operations: Array.from(cache.values()).flatMap((entry) => entry.operations),
168
+ resource,
169
+ };
170
+
171
+ if (config.command === "build") {
172
+ const dest = path.resolve(root, config.build.outDir, ".application-manifest.json");
173
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
174
+ writeFileSync(dest, JSON.stringify(manifest));
175
+ }
176
+ // Always write to the dev location so that the app can bundle the manifest content
177
+ writeFileSync(".application-manifest.json", JSON.stringify(manifest));
178
+ };
179
+
180
+ return {
181
+ name: "OperationManifest",
182
+
183
+ async configResolved(resolvedConfig: any) {
184
+ config = resolvedConfig;
185
+ },
186
+
187
+ async buildStart() {
188
+ // Read package.json on start
189
+ packageJson = JSON.parse(readFileSync("package.json", "utf-8"));
190
+
191
+ // Scan all files on startup
192
+ const files = await glob("src/**/*.{ts,tsx,js,jsx}");
193
+
194
+ for (const file of files) {
195
+ const fullPath = path.resolve(root, file);
196
+ await processFile(fullPath);
197
+ }
198
+
199
+ // We don't want to do this here on builds cause it just gets overwritten anyways. We'll call it on writeBundle instead.
200
+ if (config.command === "serve") {
201
+ await generateManifest();
202
+ }
203
+ },
204
+
205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
+ configureServer(server: any) {
207
+ server.watcher.on("change", async (file: string) => {
208
+ if (file.endsWith("package.json")) {
209
+ packageJson = JSON.parse(readFileSync("package.json", "utf-8"));
210
+ await generateManifest();
211
+ } else if (file.match(/\.(jsx?|tsx?)$/)) {
212
+ await processFile(file);
213
+ await generateManifest();
214
+ }
215
+ });
216
+ },
217
+
218
+ async writeBundle() {
219
+ await generateManifest();
220
+ },
221
+ };
222
+ };
223
+
224
+ // Sort the definitions in this document so that operations come before fragments,
225
+ // and so that each kind of definition is sorted by name.
226
+ export function sortTopLevelDefinitions(query: DocumentNode): DocumentNode {
227
+ const definitions = [...query.definitions];
228
+ // We want to avoid unnecessary dependencies, so write out a comparison
229
+ // function instead of using _.orderBy.
230
+ definitions.sort((a, b) => {
231
+ // This is a reverse sort by kind, so that OperationDefinition precedes FragmentDefinition.
232
+ if (a.kind > b.kind) {
233
+ return -1;
234
+ }
235
+ if (a.kind < b.kind) {
236
+ return 1;
237
+ }
238
+
239
+ // Extract the name from each definition. Jump through some hoops because
240
+ // non-executable definitions don't have to have names (even though any
241
+ // DocumentNode actually passed here should only have executable
242
+ // definitions).
243
+ const aName = a.kind === "OperationDefinition" || a.kind === "FragmentDefinition" ? a.name?.value ?? "" : "";
244
+ const bName = b.kind === "OperationDefinition" || b.kind === "FragmentDefinition" ? b.name?.value ?? "" : "";
245
+
246
+ // Sort by name ascending.
247
+ if (aName < bName) {
248
+ return -1;
249
+ }
250
+ if (aName > bName) {
251
+ return 1;
252
+ }
253
+
254
+ // Assuming that the document is "valid", no operation or fragment name can appear
255
+ // more than once, so we don't need to differentiate further to have a deterministic
256
+ // sort.
257
+ return 0;
258
+ });
259
+ return {
260
+ ...query,
261
+ definitions,
262
+ };
263
+ }
264
+
265
+ function removeClientDirective(doc: DocumentNode) {
266
+ return visit(doc, {
267
+ OperationDefinition(node) {
268
+ return {
269
+ ...node,
270
+ directives: node.directives?.filter((d) => d.name.value !== "prefetch" && d.name.value !== "tool"),
271
+ };
272
+ },
273
+ });
274
+ }
@@ -0,0 +1 @@
1
+ import "@testing-library/jest-dom/vitest";
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ test: {
7
+ environment: "happy-dom",
8
+ setupFiles: ["./vitest-setup.ts"],
9
+ mockReset: true,
10
+ unstubGlobals: true,
11
+ },
12
+ });