diff --git a/packages/libraries/laboratory/package.json b/packages/libraries/laboratory/package.json
index 8857c58d9e3..a03431fa67e 100644
--- a/packages/libraries/laboratory/package.json
+++ b/packages/libraries/laboratory/package.json
@@ -29,16 +29,17 @@
"peerDependencies": {
"@tanstack/react-form": "^1.23.8",
"date-fns": "^4.1.0",
- "graphql-ws": "^6.0.6",
"lucide-react": "^0.548.0",
"lz-string": "^1.5.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
+ "subscriptions-transport-ws": "^0.11.0",
"tslib": "^2.8.1",
"zod": "^4.1.12"
},
"dependencies": {
"@base-ui/react": "^1.1.0",
+ "@graphql-tools/url-loader": "^9.1.0",
"radix-ui": "^1.4.3",
"react-zoom-pan-pinch": "^3.7.0",
"uuid": "^13.0.0"
@@ -98,7 +99,6 @@
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"graphql": "^16.12.0",
- "graphql-ws": "^6.0.6",
"lodash": "^4.18.1",
"lucide-react": "^0.548.0",
"lz-string": "^1.5.0",
@@ -114,6 +114,7 @@
"react-shadow": "^20.6.0",
"rollup-plugin-typescript2": "^0.36.0",
"sonner": "^2.0.7",
+ "subscriptions-transport-ws": "^0.11.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-scoped-preflight": "^3.5.7",
diff --git a/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx
index 1194532881e..5170d8fab75 100644
--- a/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx
+++ b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx
@@ -402,7 +402,7 @@ const LaboratoryContent = () => {
>
Preflight Script
- {/*
+
{
const tab =
@@ -416,7 +416,7 @@ const LaboratoryContent = () => {
}}
>
Settings
- */}
+
Settings
@@ -514,7 +514,10 @@ export const Laboratory = (
const pluginsApi = usePlugins(props);
const testsApi = useTests(props);
const tabsApi = useTabs(props);
- const endpointApi = useEndpoint(props);
+ const endpointApi = useEndpoint({
+ ...props,
+ settingsApi,
+ });
const collectionsApi = useCollections({
...props,
tabsApi,
diff --git a/packages/libraries/laboratory/src/components/laboratory/operation.tsx b/packages/libraries/laboratory/src/components/laboratory/operation.tsx
index f9ec9a3b9b5..1cbf408c776 100644
--- a/packages/libraries/laboratory/src/components/laboratory/operation.tsx
+++ b/packages/libraries/laboratory/src/components/laboratory/operation.tsx
@@ -392,7 +392,7 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
)}
{historyItem ? (
- {historyItem?.status && (
+ {!!historyItem?.status && (
;
+ }) ?? {
+ status: 0,
+ headers: {},
+ };
+
+ delete response.extensions?.request;
+ delete response.extensions?.response;
+
+ if (Object.keys(response.extensions ?? {}).length === 0) {
+ delete response.extensions;
+ }
+
+ const status = extensionsResponse.status;
const duration = performance.now() - startTime;
- const responseText = await response.text();
+ const responseText = JSON.stringify(response, null, 2);
const size = responseText.length;
const newItemHistory = addHistory({
status,
duration,
size,
- headers: JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2),
+ headers: JSON.stringify(extensionsResponse.headers, null, 2),
operation,
preflightLogs: result?.logs ?? [],
response: responseText,
diff --git a/packages/libraries/laboratory/src/components/laboratory/settings.tsx b/packages/libraries/laboratory/src/components/laboratory/settings.tsx
index 020297ac586..92f6a9802f2 100644
--- a/packages/libraries/laboratory/src/components/laboratory/settings.tsx
+++ b/packages/libraries/laboratory/src/components/laboratory/settings.tsx
@@ -18,7 +18,6 @@ const settingsFormSchema = z.object({
protocol: z.enum(['SSE', 'GRAPHQL_SSE', 'WS', 'LEGACY_WS']),
}),
introspection: z.object({
- queryName: z.string().optional(),
method: z.enum(['GET', 'POST']).optional(),
schemaDescription: z.boolean().optional(),
}),
@@ -87,8 +86,12 @@ export const Settings = () => {
field.handleChange(Number(e.target.value))}
+ value={field.state.value ?? ''}
+ onChange={e =>
+ field.handleChange(
+ e.target.value === '' ? undefined : Number(e.target.value),
+ )
+ }
/>
);
@@ -102,8 +105,12 @@ export const Settings = () => {
field.handleChange(Number(e.target.value))}
+ value={field.state.value ?? ''}
+ onChange={e =>
+ field.handleChange(
+ e.target.value === '' ? undefined : Number(e.target.value),
+ )
+ }
/>
);
@@ -115,7 +122,7 @@ export const Settings = () => {
Use GET for queries
@@ -175,20 +182,6 @@ export const Settings = () => {
-
- {field => {
- return (
-
- Query name
- field.handleChange(e.target.value)}
- />
-
- );
- }}
-
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
@@ -219,7 +212,7 @@ export const Settings = () => {
Schema description
diff --git a/packages/libraries/laboratory/src/lib/endpoint.ts b/packages/libraries/laboratory/src/lib/endpoint.ts
index f2daa3681b6..2e653ff3fe6 100644
--- a/packages/libraries/laboratory/src/lib/endpoint.ts
+++ b/packages/libraries/laboratory/src/lib/endpoint.ts
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
buildClientSchema,
- getIntrospectionQuery,
GraphQLSchema,
+ introspectionFromSchema,
type IntrospectionQuery,
} from 'graphql';
import { toast } from 'sonner';
-import z from 'zod';
+// import z from 'zod';
import { asyncInterval } from '@/lib/utils';
+import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
+import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
export interface LaboratoryEndpointState {
endpoint: string | null;
@@ -22,20 +24,11 @@ export interface LaboratoryEndpointActions {
restoreDefaultEndpoint: () => void;
}
-const GraphQLResponseErrorSchema = z
- .object({
- errors: z.array(
- z.object({
- message: z.string(),
- }),
- ),
- })
- .strict();
-
export const useEndpoint = (props: {
defaultEndpoint?: string | null;
onEndpointChange?: (endpoint: string | null) => void;
defaultSchemaIntrospection?: IntrospectionQuery | null;
+ settingsApi?: LaboratorySettingsState & LaboratorySettingsActions;
}): LaboratoryEndpointState & LaboratoryEndpointActions => {
const [endpoint, _setEndpoint] = useState(props.defaultEndpoint ?? null);
const [introspection, setIntrospection] = useState(null);
@@ -52,6 +45,8 @@ export const useEndpoint = (props: {
return introspection ? buildClientSchema(introspection) : null;
}, [introspection]);
+ const loader = useMemo(() => new UrlLoader(), []);
+
const fetchSchema = useCallback(
async (signal?: AbortSignal) => {
if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) {
@@ -65,28 +60,37 @@ export const useEndpoint = (props: {
}
try {
- const response = await fetch(endpoint, {
- signal,
- method: 'POST',
- body: JSON.stringify({
- query: getIntrospectionQuery(),
- }),
- headers: {
- 'Content-Type': 'application/json',
- },
- }).then(r => r.json());
-
- const parsedResponse = GraphQLResponseErrorSchema.safeParse(response);
-
- if (parsedResponse.success) {
- throw new Error(parsedResponse.data.errors.map(e => e.message).join('\n'));
+ const result = await loader.load(endpoint, {
+ subscriptionsEndpoint: endpoint,
+ subscriptionsProtocol:
+ (props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
+ SubscriptionProtocol.GRAPHQL_SSE,
+ credentials: props.settingsApi?.settings.fetch.credentials,
+ specifiedByUrl: true,
+ directiveIsRepeatable: true,
+ inputValueDeprecation: true,
+ retry: props.settingsApi?.settings.fetch.retry,
+ timeout: props.settingsApi?.settings.fetch.timeout,
+ useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries,
+ exposeHTTPDetailsInExtensions: true,
+ descriptions: props.settingsApi?.settings.introspection.schemaDescription ?? false,
+ method: props.settingsApi?.settings.introspection.method ?? 'POST',
+ fetch: (input: string | URL | Request, init?: RequestInit) =>
+ fetch(input, {
+ ...init,
+ signal,
+ }),
+ });
+
+ if (result.length === 0) {
+ throw new Error('Failed to fetch schema');
}
- if (response.error && typeof response.error === 'string') {
- throw new Error(response.error);
+ if (!result[0].schema) {
+ throw new Error('Failed to fetch schema');
}
- setIntrospection(response.data as IntrospectionQuery);
+ setIntrospection(introspectionFromSchema(result[0].schema));
} catch (error: unknown) {
if (
error &&
@@ -104,7 +108,12 @@ export const useEndpoint = (props: {
throw error;
}
},
- [endpoint],
+ [
+ endpoint,
+ props.settingsApi?.settings.fetch.timeout,
+ props.settingsApi?.settings.introspection.method,
+ props.settingsApi?.settings.introspection.schemaDescription,
+ ],
);
const shouldPollSchema = useMemo(() => {
@@ -129,6 +138,7 @@ export const useEndpoint = (props: {
5000,
intervalController.signal,
);
+
return () => {
intervalController.abort();
};
diff --git a/packages/libraries/laboratory/src/lib/operations.ts b/packages/libraries/laboratory/src/lib/operations.ts
index f2ad388d079..9f631d81cf1 100644
--- a/packages/libraries/laboratory/src/lib/operations.ts
+++ b/packages/libraries/laboratory/src/lib/operations.ts
@@ -1,8 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
-import type { GraphQLSchema } from 'graphql';
-import { createClient } from 'graphql-ws';
+import {
+ DocumentNode,
+ ExecutionResult,
+ getOperationAST,
+ Kind,
+ parse,
+ type GraphQLSchema,
+} from 'graphql';
import { decompressFromEncodedURIComponent } from 'lz-string';
import { v4 as uuidv4 } from 'uuid';
+import { isAsyncIterable } from '@/lib/utils';
+import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
import { LaboratoryPermission, LaboratoryPermissions } from '../components/laboratory/context';
import type {
LaboratoryCollectionOperation,
@@ -23,6 +31,14 @@ import type { LaboratoryPreflightActions, LaboratoryPreflightState } from './pre
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
import type { LaboratoryTabOperation, LaboratoryTabsActions, LaboratoryTabsState } from './tabs';
+function getOperationType(query: string): 'query' | 'mutation' | 'subscription' | null {
+ try {
+ return getOperationAST(parse(query))?.operation ?? null;
+ } catch {
+ return null;
+ }
+}
+
export interface LaboratoryOperation {
id: string;
name: string;
@@ -66,7 +82,7 @@ export interface LaboratoryOperationsActions {
operationName?: string;
onResponse?: (response: string) => void;
},
- ) => Promise;
+ ) => Promise;
stopActiveOperation: (() => void) | null;
isActiveOperationLoading: boolean;
isOperationLoading: (operationId: string) => boolean;
@@ -80,6 +96,27 @@ export interface LaboratoryOperationsCallbacks {
onOperationDelete?: (operation: LaboratoryOperation) => void;
}
+const getOperationWithFragments = (
+ document: DocumentNode,
+ operationName?: string,
+): DocumentNode => {
+ const definitions = document.definitions.filter(definition => {
+ if (
+ definition.kind === Kind.OPERATION_DEFINITION &&
+ operationName &&
+ definition.name?.value !== operationName
+ ) {
+ return false;
+ }
+ return true;
+ });
+
+ return {
+ kind: Kind.DOCUMENT,
+ definitions,
+ };
+};
+
export const useOperations = (
props: {
checkPermissions: (
@@ -326,6 +363,8 @@ export const useOperations = (
return activeOperation ? isOperationLoading(activeOperation.id) : false;
}, [activeOperation, isOperationLoading]);
+ const loader = useMemo(() => new UrlLoader(), []);
+
const runActiveOperation = useCallback(
async (
endpoint: string,
@@ -337,7 +376,7 @@ export const useOperations = (
},
plugins: LaboratoryPlugin[] = props.pluginsApi?.plugins ?? [],
pluginsState: Record = props.pluginsApi?.pluginsState ?? {},
- ) => {
+ ): Promise => {
if (!activeOperation?.query) {
return null;
}
@@ -393,101 +432,76 @@ export const useOperations = (
)
: {};
- if (activeOperation.query.startsWith('subscription')) {
- const client = createClient({
- url: endpoint.replace('http', 'ws'),
- connectionParams: {
- ...mergedHeaders,
- },
- });
+ const executor = loader.getExecutorAsync(endpoint, {
+ subscriptionsEndpoint: endpoint,
+ subscriptionsProtocol:
+ (props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
+ SubscriptionProtocol.GRAPHQL_SSE,
+ credentials: props.settingsApi?.settings.fetch.credentials,
+ specifiedByUrl: true,
+ directiveIsRepeatable: true,
+ inputValueDeprecation: true,
+ retry: props.settingsApi?.settings.fetch.retry,
+ timeout: props.settingsApi?.settings.fetch.timeout,
+ useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries,
+ exposeHTTPDetailsInExtensions: true,
+ fetch,
+ });
- client.on('connected', () => {
- console.log('connected');
- });
+ const document = getOperationWithFragments(parse(activeOperation.query));
- client.on('error', () => {
- setStopOperationsFunctions(prev => {
- const newStopOperationsFunctions = { ...prev };
- delete newStopOperationsFunctions[activeOperation.id];
- return newStopOperationsFunctions;
- });
- });
+ const abortController = new AbortController();
- client.on('closed', () => {
+ setStopOperationsFunctions(prev => ({
+ ...prev,
+ [activeOperation.id]: () => {
+ abortController.abort();
+ },
+ }));
+
+ const response = await executor({
+ document,
+ variables,
+ extensions: {
+ ...extensions,
+ headers: mergedHeaders,
+ },
+ signal: abortController.signal,
+ });
+
+ if (isAsyncIterable(response)) {
+ try {
+ for await (const item of response) {
+ options?.onResponse?.(JSON.stringify(item ?? {}));
+ }
+ } finally {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
- });
-
- client.subscribe(
- {
- query: activeOperation.query,
- variables,
- extensions,
- },
- {
- next: message => {
- options?.onResponse?.(JSON.stringify(message ?? {}));
- },
- error: () => {},
- complete: () => {},
- },
- );
+ }
- setStopOperationsFunctions(prev => ({
- ...prev,
- [activeOperation.id]: () => {
- void client.dispose();
- setStopOperationsFunctions(prev => {
- const newStopOperationsFunctions = { ...prev };
- delete newStopOperationsFunctions[activeOperation.id];
- return newStopOperationsFunctions;
- });
- },
- }));
-
- return Promise.resolve(new Response());
+ return null;
}
- const abortController = new AbortController();
-
- const response = fetch(endpoint, {
- method: 'POST',
- credentials: props.settingsApi?.settings.fetch.credentials,
- body: JSON.stringify({
- query: activeOperation.query,
- operationName: options?.operationName,
- variables,
- extensions,
- }),
- headers: {
- ...mergedHeaders,
- 'Content-Type': 'application/json',
- },
- signal: abortController.signal,
- }).finally(() => {
- setStopOperationsFunctions(prev => {
- const newStopOperationsFunctions = { ...prev };
- delete newStopOperationsFunctions[activeOperation.id];
+ if (response.extensions?.response?.body) {
+ delete response.extensions.response.body;
+ }
- return newStopOperationsFunctions;
- });
+ setStopOperationsFunctions(prev => {
+ const newStopOperationsFunctions = { ...prev };
+ delete newStopOperationsFunctions[activeOperation.id];
+ return newStopOperationsFunctions;
});
- setStopOperationsFunctions(prev => ({
- ...prev,
- [activeOperation.id]: () => abortController.abort(),
- }));
-
return response;
},
- [activeOperation, props.preflightApi, props.envApi, props.pluginsApi],
+ [activeOperation, props.preflightApi, props.envApi, props.pluginsApi, props.settingsApi],
);
const isOperationSubscription = useCallback((operation: LaboratoryOperation) => {
- return operation.query?.startsWith('subscription') ?? false;
+ return getOperationType(operation.query) === 'subscription';
}, []);
const isActiveOperationSubscription = useMemo(() => {
diff --git a/packages/libraries/laboratory/src/lib/settings.ts b/packages/libraries/laboratory/src/lib/settings.ts
index 8b81a4b6d00..20edcfe701b 100644
--- a/packages/libraries/laboratory/src/lib/settings.ts
+++ b/packages/libraries/laboratory/src/lib/settings.ts
@@ -11,12 +11,48 @@ export type LaboratorySettings = {
protocol: 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS';
};
introspection: {
- queryName?: string;
method?: 'GET' | 'POST';
schemaDescription?: boolean;
};
};
+export const defaultLaboratorySettings: LaboratorySettings = {
+ fetch: {
+ credentials: 'same-origin',
+ timeout: 10000,
+ retry: 3,
+ useGETForQueries: false,
+ },
+ subscriptions: {
+ protocol: 'WS',
+ },
+ introspection: {
+ method: 'POST',
+ schemaDescription: false,
+ },
+};
+
+export const normalizeLaboratorySettings = (
+ settings?: Partial | null,
+): LaboratorySettings => ({
+ fetch: {
+ credentials: settings?.fetch?.credentials ?? defaultLaboratorySettings.fetch.credentials,
+ timeout: settings?.fetch?.timeout ?? defaultLaboratorySettings.fetch.timeout,
+ retry: settings?.fetch?.retry ?? defaultLaboratorySettings.fetch.retry,
+ useGETForQueries:
+ settings?.fetch?.useGETForQueries ?? defaultLaboratorySettings.fetch.useGETForQueries,
+ },
+ subscriptions: {
+ protocol: settings?.subscriptions?.protocol ?? defaultLaboratorySettings.subscriptions.protocol,
+ },
+ introspection: {
+ method: settings?.introspection?.method ?? defaultLaboratorySettings.introspection.method,
+ schemaDescription:
+ settings?.introspection?.schemaDescription ??
+ defaultLaboratorySettings.introspection.schemaDescription,
+ },
+});
+
export interface LaboratorySettingsState {
settings: LaboratorySettings;
}
@@ -30,28 +66,14 @@ export const useSettings = (props: {
onSettingsChange?: (settings: LaboratorySettings | null) => void;
}): LaboratorySettingsState & LaboratorySettingsActions => {
const [settings, _setSettings] = useState(
- props.defaultSettings ?? {
- fetch: {
- credentials: 'same-origin',
- timeout: 10000,
- retry: 3,
- useGETForQueries: false,
- },
- subscriptions: {
- protocol: 'WS',
- },
- introspection: {
- queryName: 'IntrospectionQuery',
- method: 'POST',
- schemaDescription: false,
- },
- },
+ normalizeLaboratorySettings(props.defaultSettings),
);
const setSettings = useCallback(
(settings: LaboratorySettings) => {
- _setSettings(settings);
- props.onSettingsChange?.(settings);
+ const normalizedSettings = normalizeLaboratorySettings(settings);
+ _setSettings(normalizedSettings);
+ props.onSettingsChange?.(normalizedSettings);
},
[props],
);
diff --git a/packages/libraries/laboratory/src/lib/utils.ts b/packages/libraries/laboratory/src/lib/utils.ts
index 2f4142e3ebc..8d81f279660 100644
--- a/packages/libraries/laboratory/src/lib/utils.ts
+++ b/packages/libraries/laboratory/src/lib/utils.ts
@@ -40,3 +40,7 @@ export async function asyncInterval(
});
}
}
+
+export function isAsyncIterable(val: unknown): val is AsyncIterable {
+ return typeof Object(val)[Symbol.asyncIterator] === 'function';
+}
diff --git a/packages/libraries/render-laboratory/src/index.ts b/packages/libraries/render-laboratory/src/index.ts
index 2147449e611..985ed96574e 100644
--- a/packages/libraries/render-laboratory/src/index.ts
+++ b/packages/libraries/render-laboratory/src/index.ts
@@ -1,4 +1,5 @@
import type { GraphiQLOptions } from 'graphql-yoga';
+import type { LaboratoryProps } from '@graphql-hive/laboratory';
import {
editorWorkerService,
favicon,
@@ -9,6 +10,30 @@ import {
typescriptWorker,
} from './laboratory.js';
+const mapGraphiQLOptionsToLaboratoryProps = (opts?: GraphiQLOptions): LaboratoryProps => {
+ if (!opts) {
+ return {};
+ }
+
+ return {
+ defaultSettings: {
+ fetch: {
+ credentials: opts.credentials ?? 'same-origin',
+ timeout: opts.timeout,
+ retry: opts.retry,
+ useGETForQueries: opts.useGETForQueries,
+ },
+ subscriptions: {
+ protocol: opts.subscriptionsProtocol ?? 'WS',
+ },
+ introspection: {
+ method: opts.method,
+ schemaDescription: opts.schemaDescription,
+ },
+ },
+ } satisfies LaboratoryProps;
+};
+
export const renderLaboratory = (opts?: GraphiQLOptions) => /* HTML */ `
@@ -63,7 +88,10 @@ export const renderLaboratory = (opts?: GraphiQLOptions) => /* HTML */ `
${js};
- HiveLaboratory.renderLaboratory(window.document.querySelector('#root'));
+ HiveLaboratory.renderLaboratory(
+ window.document.querySelector('#root'),
+ ${JSON.stringify(mapGraphiQLOptionsToLaboratoryProps(opts))},
+ );