From a3e52a0cecfe96a6e27ab861ef186a047937c3f2 Mon Sep 17 00:00:00 2001 From: Shane Krueger Date: Mon, 11 Nov 2024 16:05:41 -0500 Subject: [PATCH] Add GraphQLWs subscription transport option for GraphiQL (#1162) --- README.md | 4 +- src/Ui.GraphiQL/GraphiQLOptions.cs | 5 ++ src/Ui.GraphiQL/Internal/GraphiQLPageModel.cs | 3 +- src/Ui.GraphiQL/Internal/graphiql.cshtml | 82 ++++++++++++++++++- .../GraphQL.Server.Ui.GraphiQL.approved.txt | 1 + .../GraphQL.Server.Ui.GraphiQL.approved.txt | 1 + 6 files changed, 91 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3210b30f..e484444b 100644 --- a/README.md +++ b/README.md @@ -853,7 +853,9 @@ app.UseGraphQL("/graphql", options => }); ``` -Please note that the included UI packages are configured to use the `graphql-ws` sub-protocol. +Please note that the included UI packages are configured to use the `graphql-ws` sub-protocol by +default. You may use the `graphql-transport-ws` sub-protocol with the GraphiQL package by setting +the `GraphQLWsSubscriptions` option to `true` when configuring the GraphiQL middleware. ### Customizing middleware behavior diff --git a/src/Ui.GraphiQL/GraphiQLOptions.cs b/src/Ui.GraphiQL/GraphiQLOptions.cs index 868bc4b9..e09e3b47 100644 --- a/src/Ui.GraphiQL/GraphiQLOptions.cs +++ b/src/Ui.GraphiQL/GraphiQLOptions.cs @@ -53,4 +53,9 @@ public class GraphiQLOptions /// See . /// public RequestCredentials RequestCredentials { get; set; } = RequestCredentials.SameOrigin; + + /// + /// Use the graphql-ws package instead of the subscription-transports-ws package for subscriptions. + /// + public bool GraphQLWsSubscriptions { get; set; } } diff --git a/src/Ui.GraphiQL/Internal/GraphiQLPageModel.cs b/src/Ui.GraphiQL/Internal/GraphiQLPageModel.cs index 02471628..99f33d6a 100644 --- a/src/Ui.GraphiQL/Internal/GraphiQLPageModel.cs +++ b/src/Ui.GraphiQL/Internal/GraphiQLPageModel.cs @@ -50,7 +50,8 @@ public string Render() .Replace("@Model.Headers", JsonSerialize(headers)) .Replace("@Model.HeaderEditorEnabled", _options.HeaderEditorEnabled ? "true" : "false") .Replace("@Model.GraphiQLElement", "GraphiQL") - .Replace("@Model.RequestCredentials", requestCredentials); + .Replace("@Model.RequestCredentials", requestCredentials) + .Replace("@Model.GraphQLWs", _options.GraphQLWsSubscriptions ? "true" : "false"); // Here, fully-qualified, absolute and relative URLs are supported for both the // GraphQLEndPoint and SubscriptionsEndPoint. Those paths can be passed unmodified diff --git a/src/Ui.GraphiQL/Internal/graphiql.cshtml b/src/Ui.GraphiQL/Internal/graphiql.cshtml index 3881134a..d3f7825a 100644 --- a/src/Ui.GraphiQL/Internal/graphiql.cshtml +++ b/src/Ui.GraphiQL/Internal/graphiql.cshtml @@ -77,6 +77,11 @@ integrity="sha384-ArTEHLNWIe9TuoDpFEtD/NeztNdWn3SdmWwMiAuZaSJeOaYypEGzeQoBxuPO+ORM" crossorigin="anonymous" > + @@ -188,14 +193,85 @@ // if location is absolute (e.g. "/api") then prepend host only return (window.location.protocol === "http:" ? "ws://" : "wss://") + window.location.host + subscriptionsEndPoint; } + const subscriptionEndPoint = getSubscriptionsEndPoint(); // Enable Subscriptions via WebSocket - var subscriptionsClient = new window.SubscriptionsTransportWs.SubscriptionClient(getSubscriptionsEndPoint(), { reconnect: true }); - function subscriptionsFetcher(graphQLParams, fetcherOpts = { headers: {} }) { + let subscriptionsClient = null; + function subscriptionsTransportWsFetcher(graphQLParams, fetcherOpts = { headers: {} }) { + if (!subscriptionsClient) + subscriptionsClient = new window.SubscriptionsTransportWs.SubscriptionClient(subscriptionEndPoint, { reconnect: true }); return window.GraphiQLSubscriptionsFetcher.graphQLFetcher(subscriptionsClient, function (_graphQLParams) { return graphQLFetcher(_graphQLParams, fetcherOpts); })(graphQLParams); } + + function isSubscription(operationName, documentAST) { + if (!documentAST.definitions || !documentAST.definitions.length || !documentAST.definitions.filter) return false; + let definitions = documentAST.definitions.filter(function (def) { return def.kind === 'OperationDefinition'; }); + if (operationName) definitions = definitions.filter(function (def) { return def.name && def.name.value === operationName; }); + if (definitions.length === 0) return false; + return definitions[0].operation === 'subscription'; + } + + let wsClient = null; + function graphQLWsFetcher(payload, fetcherOpts) { + if (!fetcherOpts || !fetcherOpts.documentAST || !isSubscription(payload.operationName, fetcherOpts.documentAST)) + return graphQLFetcher(payload, fetcherOpts); + if (!wsClient) { + wsClient = graphqlWs.createClient({ url: subscriptionEndPoint }); + } + let deferred = null; + const pending = []; + let throwMe = null, + done = false; + const dispose = wsClient.subscribe(payload, { + next: (data) => { + pending.push(data); + if (deferred) deferred.resolve(false); + }, + error: (err) => { + if (err instanceof Error) { + throwMe = err; + } else if (err instanceof CloseEvent) { + throwMe = new Error(`Socket closed with event ${err.code} ${err.reason || ""}`.trim()); + } else { + // GraphQLError[] + throwMe = new Error(err.map(({ message }) => message).join(", ")); + } + if (deferred) deferred.reject(throwMe); + }, + complete: () => { + done = true; + if (deferred) deferred.resolve(true); + }, + }); + + return { + [Symbol.asyncIterator]: function() { + return this; + }, + next: function() { + if (done) return Promise.resolve({ done: true, value: undefined }); + if (throwMe) return Promise.reject(throwMe); + if (pending.length) return Promise.resolve({ value: pending.shift() }); + return new Promise(function(resolve, reject) { + deferred = { resolve, reject }; + }).then(function(result) { + if (result) { + return { done: true, value: undefined }; + } else { + return { value: pending.shift() }; + } + }); + }, + return: function() { + dispose(); + return Promise.resolve({ done: true, value: undefined }); + } + }; + } + + const subscriptionFetcher = (@Model.GraphQLWs) ? graphQLWsFetcher : subscriptionsTransportWsFetcher; // Render into the body. // See the README in the top level of this module to learn more about @@ -203,7 +279,7 @@ // additional child elements. ReactDOM.render( React.createElement(@Model.GraphiQLElement, { - fetcher: subscriptionsFetcher, + fetcher: subscriptionFetcher, query: parameters.query, variables: parameters.variables, operationName: parameters.operationName, diff --git a/tests/ApiApprovalTests/net80+netcoreapp31/GraphQL.Server.Ui.GraphiQL.approved.txt b/tests/ApiApprovalTests/net80+netcoreapp31/GraphQL.Server.Ui.GraphiQL.approved.txt index 4068d776..fe90f195 100644 --- a/tests/ApiApprovalTests/net80+netcoreapp31/GraphQL.Server.Ui.GraphiQL.approved.txt +++ b/tests/ApiApprovalTests/net80+netcoreapp31/GraphQL.Server.Ui.GraphiQL.approved.txt @@ -16,6 +16,7 @@ namespace GraphQL.Server.Ui.GraphiQL public GraphiQLOptions() { } public bool ExplorerExtensionEnabled { get; set; } public string GraphQLEndPoint { get; set; } + public bool GraphQLWsSubscriptions { get; set; } public bool HeaderEditorEnabled { get; set; } public System.Collections.Generic.Dictionary? Headers { get; set; } public System.Func IndexStream { get; set; } diff --git a/tests/ApiApprovalTests/netstandard20/GraphQL.Server.Ui.GraphiQL.approved.txt b/tests/ApiApprovalTests/netstandard20/GraphQL.Server.Ui.GraphiQL.approved.txt index 5eecf315..c9cfdec2 100644 --- a/tests/ApiApprovalTests/netstandard20/GraphQL.Server.Ui.GraphiQL.approved.txt +++ b/tests/ApiApprovalTests/netstandard20/GraphQL.Server.Ui.GraphiQL.approved.txt @@ -16,6 +16,7 @@ namespace GraphQL.Server.Ui.GraphiQL public GraphiQLOptions() { } public bool ExplorerExtensionEnabled { get; set; } public string GraphQLEndPoint { get; set; } + public bool GraphQLWsSubscriptions { get; set; } public bool HeaderEditorEnabled { get; set; } public System.Collections.Generic.Dictionary? Headers { get; set; } public System.Func IndexStream { get; set; }