diff --git a/packages/graphql-playground-react/package.json b/packages/graphql-playground-react/package.json index aacf1f11b..21242098b 100644 --- a/packages/graphql-playground-react/package.json +++ b/packages/graphql-playground-react/package.json @@ -121,6 +121,7 @@ "cuid": "^1.3.8", "graphiql": "^0.17.5", "graphql": "^15.3.0", + "graphql-ws": "^4.5.0", "immutable": "^4.0.0-rc.9", "isomorphic-fetch": "^2.2.1", "js-yaml": "^3.10.0", diff --git a/packages/graphql-playground-react/src/components/EndpointPopup.tsx b/packages/graphql-playground-react/src/components/EndpointPopup.tsx index 194ed2de0..f99ecfc50 100644 --- a/packages/graphql-playground-react/src/components/EndpointPopup.tsx +++ b/packages/graphql-playground-react/src/components/EndpointPopup.tsx @@ -4,6 +4,8 @@ import Popup from './Popup' import { throttle } from 'lodash' import { Button } from './Button' import { styled, css } from '../styled' +import { createClient } from 'graphql-ws'; + // @ts-ignore import imageSource from '../assets/logo.png' @@ -20,7 +22,37 @@ export interface State { export default class EndpointPopup extends React.Component { checkEndpoint = throttle(() => { - if (this.state.endpoint.match(/^https?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?.*$/)) { + if (this.state.endpoint.match(/^(https?|wss?):\/\/\w+(\.\w+)*(:[0-9]+)?\/?.*$/)) { + if (this.state.endpoint.match(/^(wss?)/)) { + const client = createClient({ + url: this.state.endpoint, + retryAttempts:0 + }); + const unsubscribe = client.subscribe( + { + query: `{ + __schema { + queryType { + kind + } + } + }`, + }, + { + next: () => { + this.setState({ valid: true }) + unsubscribe() + }, + error: () => { + this.setState({ valid: false }); + unsubscribe() + }, + complete: () => {}, + }, + ); + return; + } + fetch(this.state.endpoint, { method: 'post', headers: { diff --git a/packages/graphql-playground-react/src/state/sessions/WebSocketLink.ts b/packages/graphql-playground-react/src/state/sessions/WebSocketLink.ts new file mode 100644 index 000000000..56f91ba7b --- /dev/null +++ b/packages/graphql-playground-react/src/state/sessions/WebSocketLink.ts @@ -0,0 +1,43 @@ +import { ApolloLink, Operation, FetchResult, Observable } from 'apollo-link'; +import { print, GraphQLError } from 'graphql'; +import { Client } from 'graphql-ws'; + +export class WebSocketLink extends ApolloLink { + + constructor(private client: Client) { + super(); + } + + public request(operation: Operation): Observable { + return new Observable((sink) => { + return this.client.subscribe( + { ...operation, query: print(operation.query) }, + { + next: sink.next.bind(sink), + complete: sink.complete.bind(sink), + error: (err) => { + if (err instanceof Error) { + sink.error(err); + } else if (err instanceof CloseEvent) { + sink.error( + new Error( + `Socket closed with event ${err.code}` + err.reason + ? `: ${err.reason}` // reason will be available on clean closes + : '', + ), + ); + } else { + sink.error( + new Error( + (err as GraphQLError[]) + .map(({ message }) => message) + .join(', '), + ), + ); + } + }, + }, + ); + }); + } +} diff --git a/packages/graphql-playground-react/src/state/sessions/fetchingSagas.ts b/packages/graphql-playground-react/src/state/sessions/fetchingSagas.ts index 43c8e5443..ddff6fe5e 100644 --- a/packages/graphql-playground-react/src/state/sessions/fetchingSagas.ts +++ b/packages/graphql-playground-react/src/state/sessions/fetchingSagas.ts @@ -1,8 +1,6 @@ import { ApolloLink, execute } from 'apollo-link' import { parseHeaders } from '../../components/Playground/util/parseHeaders' -import { SubscriptionClient } from 'subscriptions-transport-ws' import { HttpLink } from 'apollo-link-http' -import { WebSocketLink } from 'apollo-link-ws' import { isSubscription } from '../../components/Playground/util/hasSubscription' import { takeLatest, @@ -38,6 +36,10 @@ import { Session, ResponseRecord } from './reducers' import { addHistoryItem } from '../history/actions' import { safely } from '../../utils' import { set } from 'immutable' +import { SubscriptionClient as SubscriptionClientSTWS } from 'subscriptions-transport-ws' +import { WebSocketLink as WebSocketLinkALW } from 'apollo-link-ws' +import { createClient as createSubscriptionClient, Client as SubscriptionClientGWS } from 'graphql-ws' +import { WebSocketLink as WebSocketLinkGW } from './WebSocketLink' // tslint:disable let subscriptionEndpoint @@ -50,18 +52,22 @@ export interface LinkCreatorProps { endpoint: string headers?: Headers credentials?: string + subscriptionTransport?: string } export interface Headers { [key: string]: string | number | null } +const isWSEndpoint = (endpoint: string): boolean => !!endpoint.match(/wss?/); + export const defaultLinkCreator = ( session: LinkCreatorProps, subscriptionEndpoint?: string, -): { link: ApolloLink; subscriptionClient?: SubscriptionClient } => { +): { link: ApolloLink; subscriptionClient?: SubscriptionClientGWS | SubscriptionClientSTWS } => { + let connectionParams = {} - const { headers, credentials } = session + const { headers, credentials, subscriptionTransport } = session if (headers) { connectionParams = { ...headers } @@ -73,21 +79,53 @@ export const defaultLinkCreator = ( credentials, }) - if (!subscriptionEndpoint) { - return { link: httpLink } + // ws endpoint => graphql-ws default link + if (isWSEndpoint(session.endpoint)) { + const subscriptionClient = createSubscriptionClient({ + retryAttempts: 1000, + retryWait: () => new Promise(resolve => setTimeout(resolve, 20000)), + lazy: true, + connectionParams, + url: session.endpoint, + }) + + return { + link: new WebSocketLinkGW(subscriptionClient), + subscriptionClient, + } + } + + // http endpoint & graphql-ws => default link = http + graphql-ws subscriptions + if (subscriptionTransport === 'graphql-ws') { + const subscriptionClient = createSubscriptionClient({ + retryWait: () => new Promise(resolve => setTimeout(resolve, 20000)), + lazy: true, + connectionParams, + url: subscriptionEndpoint || session.endpoint.replace('http', 'ws'), + }) + + return { + subscriptionClient, + link: new WebSocketLinkGW(subscriptionClient) + } } - const subscriptionClient = new SubscriptionClient(subscriptionEndpoint, { - timeout: 20000, - lazy: true, - connectionParams, - }) + // http endpoint => default link = http + subscriptions-transport-ws subscriptions + const subscriptionClient = new SubscriptionClientSTWS( + subscriptionEndpoint || session.endpoint.replace('http', 'ws'), + { + timeout: 20000, + lazy: true, + connectionParams, + } + ) + + const webSocketLink = new WebSocketLinkALW(subscriptionClient); - const webSocketLink = new WebSocketLink(subscriptionClient) return { link: ApolloLink.split( operation => isSubscription(operation), - webSocketLink as any, + webSocketLink, httpLink, ), subscriptionClient, @@ -107,6 +145,12 @@ export function setLinkCreator(newLinkCreator) { const subscriptions = {} +const isSubscriptionClientSTWS = ( + client: SubscriptionClientGWS | SubscriptionClientSTWS + ): client is SubscriptionClientSTWS => { + return !!(client as SubscriptionClientSTWS).onDisconnected +} + function* runQuerySaga(action) { // run the query const { operationName } = action.payload @@ -127,12 +171,14 @@ function* runQuerySaga(action) { if (session.tracingSupported && session.responseTracingOpen) { headers = set(headers, 'X-Apollo-Tracing', '1') } + const lol = { endpoint: session.endpoint, headers: { ...settings['request.globalHeaders'], ...headers, }, + subscriptionTransport: settings['subscriptions.protocol'], credentials: settings['request.credentials'], } @@ -143,7 +189,7 @@ function* runQuerySaga(action) { const channel = eventChannel(emitter => { let closed = false if (subscriptionClient && operationIsSubscription) { - subscriptionClient.onDisconnected(() => { + const onDisconnect = () => { closed = true emitter({ error: new Error( @@ -151,7 +197,12 @@ function* runQuerySaga(action) { ), }) emitter(END) - }) + } + if (isSubscriptionClientSTWS(subscriptionClient)) { + subscriptionClient.onDisconnected(onDisconnect) + } else { + subscriptionClient.on('closed', onDisconnect) + } } const subscription = execute(link, operation).subscribe({ next: function(value) { diff --git a/packages/graphql-playground-react/src/state/workspace/reducers.ts b/packages/graphql-playground-react/src/state/workspace/reducers.ts index cdbc11f56..f863f46a9 100644 --- a/packages/graphql-playground-react/src/state/workspace/reducers.ts +++ b/packages/graphql-playground-react/src/state/workspace/reducers.ts @@ -58,6 +58,7 @@ export const defaultSettings: ISettings = { 'schema.polling.interval': 2000, 'tracing.hideTracingResponse': true, 'tracing.tracingSupported': true, + 'subscriptions.protocol': 'subscription-transport-ws', } // tslint:disable-next-line:max-classes-per-file diff --git a/packages/graphql-playground-react/src/types.ts b/packages/graphql-playground-react/src/types.ts index 0e4c3faf1..5551d77c3 100644 --- a/packages/graphql-playground-react/src/types.ts +++ b/packages/graphql-playground-react/src/types.ts @@ -34,4 +34,5 @@ export interface ISettings { ['schema.polling.interval']: number ['tracing.hideTracingResponse']: boolean ['tracing.tracingSupported']: boolean + ['subscriptions.protocol']: 'subscription-transport-ws' | 'graphql-ws' } diff --git a/yarn.lock b/yarn.lock index 33e1f5694..e9cedbf6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8463,6 +8463,11 @@ graphql-request@^1.4.0, graphql-request@^1.5.0: dependencies: cross-fetch "2.2.2" +graphql-ws@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-4.5.0.tgz#c71c6eed34850c375156c29b1ed45cea2f9aee6b" + integrity sha512-J3PuSfOKX2y9ryOtWxOcKlizkFWyhCvPAc3hhMKMVSTcPxtWiv9oNzvAZp1HKfuQng32YQduHeX+lRDy2+F6VQ== + graphql@^15.3.0: version "15.3.0" resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.3.0.tgz#3ad2b0caab0d110e3be4a5a9b2aa281e362b5278"