@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.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/pr.yaml +24 -0
- package/.github/workflows/release.yaml +36 -0
- package/LICENSE +21 -0
- package/dist/apollo_client/client.d.ts +14 -0
- package/dist/apollo_client/provider.d.ts +5 -0
- package/dist/hooks/useOpenAiGlobal.d.ts +2 -0
- package/dist/hooks/useRequestDisplayMode.d.ts +4 -0
- package/dist/hooks/useSendFollowUpMessage.d.ts +1 -0
- package/dist/hooks/useToolEffect.d.ts +6 -0
- package/dist/hooks/useToolInput.d.ts +1 -0
- package/dist/hooks/useToolName.d.ts +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +177 -0
- package/dist/types/application-manifest.d.ts +29 -0
- package/dist/types/openai.d.ts +73 -0
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.js +210 -0
- package/dist/vite/operation_manifest_plugin.d.ts +10 -0
- package/package.json +53 -0
- package/scripts/build-vite.mjs +18 -0
- package/scripts/build.mjs +7 -0
- package/scripts/dev.mjs +21 -0
- package/scripts/shared.mjs +9 -0
- package/src/apollo_client/client.test.ts +411 -0
- package/src/apollo_client/client.ts +90 -0
- package/src/apollo_client/provider.test.tsx +41 -0
- package/src/apollo_client/provider.tsx +32 -0
- package/src/hooks/useCallTool.test.ts +46 -0
- package/src/hooks/useCallTool.ts +8 -0
- package/src/hooks/useOpenAiGlobal.test.ts +54 -0
- package/src/hooks/useOpenAiGlobal.ts +26 -0
- package/src/hooks/useRequestDisplayMode.ts +7 -0
- package/src/hooks/useSendFollowUpMessage.ts +7 -0
- package/src/hooks/useToolEffect.tsx +41 -0
- package/src/hooks/useToolInput.ts +7 -0
- package/src/hooks/useToolName.ts +7 -0
- package/src/index.ts +12 -0
- package/src/types/application-manifest.ts +32 -0
- package/src/types/openai.ts +90 -0
- package/src/vite/index.ts +1 -0
- package/src/vite/operation_manifest_plugin.ts +274 -0
- package/vitest-setup.ts +1 -0
- 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,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
|
+
};
|
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
|
+
}
|
package/vitest-setup.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|
package/vitest.config.ts
ADDED
|
@@ -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
|
+
});
|