From f25a0285295038530df2602dd67840632397ee57 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Mon, 24 Jan 2022 17:33:37 -0800 Subject: [PATCH 01/10] New GraphQLWsLink for graphql-ws subscriptions library Apollo Client currently contains `WebSocketLink` in `@apollo/client/link/ws` which uses the `subscriptions-transport-ws` library. That library is no longer actively maintained, and there is an improved fork called `graphql-ws`. The two libraries use different protocols so a different client link is required for `graphql-ws`. (While the WebSocket protocol does allow for subprotocol negotiation, neither server implementation supports this in a practical way.) This PR adds a new link `GraphQLWsLink` in `@apollo/client/link/subscriptions`. Its constructor arguments are the same as the `createClient` function in `graphql-ws` (or it can take a `Client` object returned from that function), and you need to install the optional peer dep `graphql-ws` instead of `subscriptions-transport-ws`. Once you've created the link, it works exactly like the old `WebSocketLink`. This PR changes the main subscriptions doc page to mostly document the new link, with an extra section at the bottom for the old link. The core GraphQLWsLink code is based on MIT-licensed code from the README of the graphql-ws repository. Fixes #8345. Part of https://github.com/apollographql/apollo-server/issues/6058 --- config/entryPoints.js | 1 + docs/gatsby-config.js | 1 + .../api/link/apollo-link-subscriptions.md | 42 +++++++ docs/source/api/link/apollo-link-ws.md | 10 +- docs/source/api/react/hoc.mdx | 2 +- docs/source/data/subscriptions.mdx | 89 ++++++++++----- package-lock.json | 24 ++++ package.json | 5 + src/__tests__/__snapshots__/exports.ts.snap | 6 + src/__tests__/exports.ts | 2 + .../subscriptions/__tests__/graphqlWsLink.ts | 106 ++++++++++++++++++ src/link/subscriptions/index.ts | 77 +++++++++++++ src/link/ws/__tests__/webSocketLink.ts | 2 +- 13 files changed, 330 insertions(+), 37 deletions(-) create mode 100644 docs/source/api/link/apollo-link-subscriptions.md create mode 100644 src/link/subscriptions/__tests__/graphqlWsLink.ts create mode 100644 src/link/subscriptions/index.ts diff --git a/config/entryPoints.js b/config/entryPoints.js index 67e6e2822db..b1fe6d0db32 100644 --- a/config/entryPoints.js +++ b/config/entryPoints.js @@ -12,6 +12,7 @@ const entryPoints = [ { dirs: ['link', 'persisted-queries'] }, { dirs: ['link', 'retry'] }, { dirs: ['link', 'schema'] }, + { dirs: ['link', 'subscriptions'] }, { dirs: ['link', 'utils'] }, { dirs: ['link', 'ws'] }, { dirs: ['react'] }, diff --git a/docs/gatsby-config.js b/docs/gatsby-config.js index 688faed6d98..0efc6e8bd72 100644 --- a/docs/gatsby-config.js +++ b/docs/gatsby-config.js @@ -109,6 +109,7 @@ module.exports = { 'api/link/apollo-link-rest', 'api/link/apollo-link-retry', 'api/link/apollo-link-schema', + 'api/link/apollo-link-subscriptions', 'api/link/apollo-link-ws', 'api/link/community-links' ], diff --git a/docs/source/api/link/apollo-link-subscriptions.md b/docs/source/api/link/apollo-link-subscriptions.md new file mode 100644 index 00000000000..81df581dffd --- /dev/null +++ b/docs/source/api/link/apollo-link-subscriptions.md @@ -0,0 +1,42 @@ +--- +title: Subscriptions Link +sidebar_title: Subscriptions (newer protocol) +description: Execute subscriptions (or other GraphQL operations) over WebSocket with the `graphql-ws` library +api_reference: true +--- + +> We recommend reading [Apollo Link overview](./introduction/) before learning about individual links. + +The `GraphQLWsLink` is a [terminating link](./introduction/#the-terminating-link) that's used most commonly with GraphQL [subscriptions](../../data/subscriptions/) (which usually communicate over WebSocket), although you can send queries and mutations over WebSocket as well. + +`GraphQLWsLink` requires the [`graphql-ws`](https://www.npmjs.com/package/graphql-ws) library. Install it in your project like so: + +```shell +npm install graphql-ws +``` + +> **Note**: This link works with the newer `graphql-ws` library. If your server uses the older `subscriptions-transport-ws`, you should use the [`WebSocketLink` link from `@apollo/client/link/ws](./apollo-link-ws) instead. + +## Constructor + +```js +import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; + +const link = new GraphQLWsLink({ + url: "ws://localhost:3000/subscriptions", +}); +``` + +### Options + +The `GraphQLWsLink` constructor takes a single object. This can either be a `Client` returned from the `graphql-ws` `createClient` function, or an options object that will be passed directly to the `createClient` function. + +If you are passing an options object, the one required option is `url`, which is the URL (typically starting with `ws://` or `wss://`, which are the equivalents of `http://` and `https://` respectively) to your WebSocket server. + +Full documentation of supported options can be found in [the `graphql-ws` docs for `ClientOptions`](https://github.com/enisdenjo/graphql-ws/blob/master/docs/interfaces/client.ClientOptions.md). + + + +## Usage + +See [Subscriptions](../../data/subscriptions/). diff --git a/docs/source/api/link/apollo-link-ws.md b/docs/source/api/link/apollo-link-ws.md index df69d78314d..46162e5e452 100644 --- a/docs/source/api/link/apollo-link-ws.md +++ b/docs/source/api/link/apollo-link-ws.md @@ -1,7 +1,7 @@ --- title: WebSocket Link -sidebar_title: WebSocket -description: Execute subscriptions (or other GraphQL operations) over WebSocket +sidebar_title: WebSocket (newer protocol) +description: Execute subscriptions (or other GraphQL operations) over WebSocket with the `subscriptions-transport-ws` library api_reference: true --- @@ -15,6 +15,8 @@ The `WebSocketLink` is a [terminating link](./introduction/#the-terminating-link npm install subscriptions-transport-ws ``` +> **Note**: The `subscriptions-transport-ws` library is not actively maintained. We recommend the use of the `graphql-ws` library instead. These libraries layer different protocols on top of WebSockets, so you do need to ensure you are using the same library in your server and any clients that you support. To use `graphql-ws` from Apollo Client, use the [`GraphQLWsLink` link from `@apollo/client/link/subscriptions](./apollo-link-subscriptions) instead. + ## Constructor ```js @@ -23,8 +25,8 @@ import { WebSocketLink } from "@apollo/client/link/ws"; const link = new WebSocketLink({ uri: "ws://localhost:3000/subscriptions", options: { - reconnect: true - } + reconnect: true, + }, }); ``` diff --git a/docs/source/api/react/hoc.mdx b/docs/source/api/react/hoc.mdx index 4c953aa4d94..66c5fc4249e 100644 --- a/docs/source/api/react/hoc.mdx +++ b/docs/source/api/react/hoc.mdx @@ -471,7 +471,7 @@ data.fetchMore({ ### `data.subscribeToMore(options)` -This function will set up a subscription, triggering updates whenever the server sends a subscription publication. This requires subscriptions to be set up on the server to properly work. Check out the [subscriptions guide](../../data/subscriptions/) and the [subscriptions-transport-ws](https://github.com/apollographql/subscriptions-transport-ws) and [graphql-subscriptions](https://github.com/apollographql/graphql-subscriptions) for more information on getting this set up. +This function will set up a subscription, triggering updates whenever the server sends a subscription publication. This requires subscriptions to be set up on the server to properly work. Check out the [subscriptions guide](../../data/subscriptions/) for more information on getting this set up. This function returns an `unsubscribe` function handler which can be used to unsubscribe later. diff --git a/docs/source/data/subscriptions.mdx b/docs/source/data/subscriptions.mdx index 8bc23ba2e49..6216533a22d 100644 --- a/docs/source/data/subscriptions.mdx +++ b/docs/source/data/subscriptions.mdx @@ -22,6 +22,14 @@ You _should_ use subscriptions for the following: * **Low-latency, real-time updates**. For example, a chat application's client wants to receive new messages as soon as they're available. +## Choice of subscription protocol + +The GraphQL spec does not define a specific way to send subscription requests. The first popular JavaScript library to implement subscriptions over WebSocket is called `subscriptions-transport-ws`. This library is no longer actively maintained; its successor is a library called `graphql-ws`. The two packages _do not use the same protocol_, so you need to make sure that your server and clients all use the same library. + +Apollo Client supports both `graphql-ws` and `subscriptions-transport-ws`. We recommend you use the newer library `graphql-ws` and this page shows how to use it. If you need to use `subscriptions-transport-ws` because your server still uses that protocol, the differences are described [at the bottom of this page](#the-older-subscriptions-transport-ws-library). + +> **Note**: When looking at the source code of an implementation to determine which protocol it supports, you will find that the libraries uses different strings as the "WebSocket subprotocol". Confusingly, `subscriptions-transport-ws` uses the `graphql-ws` subprotocol and `graphql-ws` uses the `graphql-transport-ws` subprotocol! In these docs, when we say "`graphql-ws`" we are referring to the _library_ `graphql-ws`, not the subprotocol `graphql-ws`, which is the other project. + ## Defining a subscription You define a subscription on both the server side and the client side, just like you do for queries and mutations. @@ -70,57 +78,51 @@ Whenever your GraphQL server _does_ push data to a subscribing client, that data ## Setting up the transport -Because subscriptions usually maintain a persistent connection, they shouldn't use the default HTTP transport that Apollo Client uses for queries and mutations. Instead, Apollo Client subscriptions most commonly communicate over WebSocket, via the community-maintained [`subscriptions-transport-ws`](https://github.com/apollographql/subscriptions-transport-ws) library. +Because subscriptions usually maintain a persistent connection, they shouldn't use the default HTTP transport that Apollo Client uses for queries and mutations. Instead, Apollo Client subscriptions most commonly communicate over WebSocket, via the [`graphql-ws`](https://www.npmjs.com/package/graphql-ws) library. (As mentioned [above](#choice-of-subscription-protocol), some servers use an older library called `subscriptions-transport-ws`; see [below](#the-older-subscriptions-transport-ws-library) for the changes necessary to use that library with Apollo Client.) ### 1. Install required libraries [Apollo Link](../api/link/introduction/) is a library that helps you customize Apollo Client's network communication. You can use it to define a **link chain** that modifies your operations and routes them to the appropriate destination. -To execute subscriptions over WebSocket, you can add a `WebSocketLink` to your link chain. This link requires the `subscriptions-transport-ws` library. Install it like so: +To execute subscriptions over WebSocket, you can add a `GraphQLWsLink` to your link chain. This link requires the `graphql-ws` library. Install it like so: ```bash -npm install subscriptions-transport-ws +npm install graphql-ws ``` -### 2. Initialize a `WebSocketLink` +### 2. Initialize a `GraphQLWsLink` -Import and initialize a `WebSocketLink` object in the same project file where you initialize `ApolloClient`: +Import and initialize a `GraphQLWsLink` object in the same project file where you initialize `ApolloClient`: ```js:title=index.js -import { WebSocketLink } from '@apollo/client/link/ws'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; -const wsLink = new WebSocketLink({ - uri: 'ws://localhost:4000/subscriptions', - options: { - reconnect: true - } +const wsLink = new GraphQLWsLink({ + url: 'ws://localhost:4000/subscriptions', }); ``` -Replace the value of the `uri` option with your GraphQL server's subscription-specific WebSocket endpoint. If you're using Apollo Server, see [Setting a subscription endpoint](https://www.apollographql.com/docs/apollo-server/data/subscriptions/#setting-a-subscription-endpoint). +Replace the value of the `url` option with your GraphQL server's subscription-specific WebSocket endpoint. If you're using Apollo Server, see [Setting a subscription endpoint](https://www.apollographql.com/docs/apollo-server/data/subscriptions/#setting-a-subscription-endpoint). ### 3. Split communication by operation (recommended) -Although Apollo Client _can_ use your `WebSocketLink` to execute all operation types, in most cases it should continue using HTTP for queries and mutations. This is because queries and mutations don't require a stateful or long-lasting connection, making HTTP more efficient and scalable if a WebSocket connection isn't already present. +Although Apollo Client _can_ use your `GraphQLWsLink` to execute all operation types, in most cases it should continue using HTTP for queries and mutations. This is because queries and mutations don't require a stateful or long-lasting connection, making HTTP more efficient and scalable if a WebSocket connection isn't already present. To support this, the `@apollo/client` library provides a `split` function that lets you use one of two different `Link`s, according to the result of a boolean check. -The following example expands on the previous one by initializing both a `WebSocketLink` _and_ an `HttpLink`. It then uses the `split` function to combine those two `Link`s into a _single_ `Link` that uses one or the other according to the type of operation being executed. +The following example expands on the previous one by initializing both a `GraphQLWsLink` _and_ an `HttpLink`. It then uses the `split` function to combine those two `Link`s into a _single_ `Link` that uses one or the other according to the type of operation being executed. ```js:title=index.js import { split, HttpLink } from '@apollo/client'; import { getMainDefinition } from '@apollo/client/utilities'; -import { WebSocketLink } from '@apollo/client/link/ws'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' }); -const wsLink = new WebSocketLink({ - uri: 'ws://localhost:4000/subscriptions', - options: { - reconnect: true - } +const wsLink = new GraphQLWsLink({ + url: 'ws://localhost:4000/subscriptions', }); // The split function takes three parameters: @@ -162,24 +164,20 @@ const client = new ApolloClient({ ### 5. Authenticate over WebSocket (optional) -It is often necessary to authenticate a client before allowing it to receive subscription results. To do this, you can provide a `connectionParams` option to the `WebSocketLink` constructor, like so: +It is often necessary to authenticate a client before allowing it to receive subscription results. To do this, you can provide a `connectionParams` option to the `GraphQLWsLink` constructor, like so: ```js{7-9} -import { WebSocketLink } from '@apollo/client/link/ws'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; -const wsLink = new WebSocketLink({ - uri: 'ws://localhost:4000/subscriptions', - options: { - reconnect: true, - connectionParams: { - authToken: user.authToken, - }, +const wsLink = new GraphQLWsLink({ + url: 'ws://localhost:4000/subscriptions', + connectionParams: { + authToken: user.authToken, }, }); ``` -Your `WebSocketLink` passes the `connectionParams` object to your server whenever it connects. If your server has a [SubscriptionsServer](https://www.apollographql.com/docs/graphql-subscriptions/authentication) object that's listening for WebSocket connections, it receives the `connectionParams` object and can use it to perform authentication, along with any other connection-related tasks. - +Your `GraphQLWsLink` passes the `connectionParams` object to your server whenever it connects. Your server receives the `connectionParams` object and can use it to perform authentication, along with any other connection-related tasks. ## Executing a subscription @@ -312,3 +310,32 @@ The `useSubscription` Hook accepts the following options: After being called, the `useSubscription` Hook returns a result object with the following properties: + +## The older `subscriptions-transport-ws` library + +If your server uses `subscriptions-transport-ws` instead of the newer `graphql-ws` library, you need to make a few changes to how you set up your link. + +Instead of `npm install graphql-ws`, you `npm install subscriptions-transport-ws` + +Instead of `import { GraphQLWsLink } from '@apollo/client/link/subscriptions'`, you `import { WebSocketLink } from '@apollo/client/link/ws`. + +The options passed to the link constructor are slightly different. The subscriptions URL is specified in an `uri` option instead of an `url` option. The `connectionParams` option is nested under an options object called `options` instead of being at the top level. + +Once you've created your `wsLink`, everything else in this document still applies: `useSubscription`, `subscribeToMore`, and split links work exactly the same way for both implementations. + +The following is what typical `WebSocketLink` initialization looks like: + +```js +import { WebSocketLink } from "@apollo/client/link/ws"; + +const wsLink = new GraphQLWsLink({ + uri: "ws://localhost:4000/subscriptions", + options: { + connectionParams: { + authToken: user.authToken, + }, + }, +}); +``` + +More details on `WebSocketLink`'s API can be found in [its API docs](../api/link/apollo-link-ws). diff --git a/package-lock.json b/package-lock.json index 18edff51fd8..27dd8da28a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "fetch-mock": "9.11.0", "glob": "7.2.0", "graphql": "16.0.1", + "graphql-ws": "5.5.5", "jest": "27.4.7", "jest-fetch-mock": "3.0.3", "jest-junit": "13.0.0", @@ -71,11 +72,15 @@ }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql-ws": "^5.5.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0-beta", "subscriptions-transport-ws": "^0.9.0 || ^0.11.0", "use-sync-external-store": "^1.0.0 || ^1.0.0-rc || ^1.0.0-beta" }, "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, "react": { "optional": true }, @@ -3002,6 +3007,18 @@ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" } }, + "node_modules/graphql-ws": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.5.5.tgz", + "integrity": "sha512-hvyIS71vs4Tu/yUYHPvGXsTgo0t3arU820+lT5VjZS2go0ewp2LqyCgxEN56CzOG7Iys52eRhHBiD1gGRdiQtw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/gzip-size": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-4.1.0.tgz", @@ -8591,6 +8608,13 @@ "tslib": "^2.1.0" } }, + "graphql-ws": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.5.5.tgz", + "integrity": "sha512-hvyIS71vs4Tu/yUYHPvGXsTgo0t3arU820+lT5VjZS2go0ewp2LqyCgxEN56CzOG7Iys52eRhHBiD1gGRdiQtw==", + "dev": true, + "requires": {} + }, "gzip-size": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-4.1.0.tgz", diff --git a/package.json b/package.json index 1f6f7cb4ee4..cdabb5f90c1 100644 --- a/package.json +++ b/package.json @@ -64,11 +64,15 @@ }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql-ws": "^5.5.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0-beta", "subscriptions-transport-ws": "^0.9.0 || ^0.11.0", "use-sync-external-store": "^1.0.0 || ^1.0.0-rc || ^1.0.0-beta" }, "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, "react": { "optional": true }, @@ -116,6 +120,7 @@ "fetch-mock": "9.11.0", "glob": "7.2.0", "graphql": "16.0.1", + "graphql-ws": "5.5.5", "jest": "27.4.7", "jest-fetch-mock": "3.0.3", "jest-junit": "13.0.0", diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 07fa0772c9d..be0c67ec126 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -210,6 +210,12 @@ Array [ ] `; +exports[`exports of public entry points @apollo/client/link/subscriptions 1`] = ` +Array [ + "GraphQLWsLink", +] +`; + exports[`exports of public entry points @apollo/client/link/utils 1`] = ` Array [ "createOperation", diff --git a/src/__tests__/exports.ts b/src/__tests__/exports.ts index 8f9e5edcf91..a1c7507da9b 100644 --- a/src/__tests__/exports.ts +++ b/src/__tests__/exports.ts @@ -11,6 +11,7 @@ import * as linkHTTP from "../link/http"; import * as linkPersistedQueries from "../link/persisted-queries"; import * as linkRetry from "../link/retry"; import * as linkSchema from "../link/schema"; +import * as linkSubscriptions from "../link/subscriptions"; import * as linkUtils from "../link/utils"; import * as linkWS from "../link/ws"; import * as react from "../react"; @@ -52,6 +53,7 @@ describe('exports of public entry points', () => { check("@apollo/client/link/persisted-queries", linkPersistedQueries); check("@apollo/client/link/retry", linkRetry); check("@apollo/client/link/schema", linkSchema); + check("@apollo/client/link/subscriptions", linkSubscriptions); check("@apollo/client/link/utils", linkUtils); check("@apollo/client/link/ws", linkWS); check("@apollo/client/react", react); diff --git a/src/link/subscriptions/__tests__/graphqlWsLink.ts b/src/link/subscriptions/__tests__/graphqlWsLink.ts new file mode 100644 index 00000000000..e6c3fee184c --- /dev/null +++ b/src/link/subscriptions/__tests__/graphqlWsLink.ts @@ -0,0 +1,106 @@ +import { Client } from "graphql-ws"; +import { ExecutionResult } from "graphql"; +import gql from "graphql-tag"; + +import { Observable } from "../../../utilities"; +import { execute } from "../../core"; +import { GraphQLWsLink } from ".."; + +const query = gql` + query SampleQuery { + stub { + id + } + } +`; + +const mutation = gql` + mutation SampleMutation { + stub { + id + } + } +`; + +const subscription = gql` + subscription SampleSubscription { + stub { + id + } + } +`; + +function mockClient(subscribe: Client["subscribe"]): Client { + return { + subscribe, + // GraphQLWsLink doesn't use these methods + on: () => () => {}, + dispose: () => {}, + }; +} + +async function observableToArray(o: Observable): Promise { + const out: T[] = []; + await o.forEach((v) => out.push(v)); + return out; +} + +describe("GraphQLWSlink", () => { + it("constructs", () => { + const client = mockClient(() => () => {}); + expect(() => new GraphQLWsLink(client)).not.toThrow(); + }); + + // TODO some sort of dependency injection + + // it('should pass the correct initialization parameters to the Subscription Client', () => { + // }); + + it("should call subscribe on the client for a query", async () => { + const result = { data: { data: "result" } } as ExecutionResult; + const subscribe: Client["subscribe"] = (_, sink) => { + sink.next(result); + sink.complete(); + return () => {}; + }; + const client = mockClient(subscribe); + const link = new GraphQLWsLink(client); + + const obs = execute(link, { query }); + await expect(observableToArray(obs)).resolves.toEqual([result]); + }); + + it("should call subscribe on the client for a mutation", async () => { + const result = { data: { data: "result" } } as ExecutionResult; + const subscribe: Client["subscribe"] = (_, sink) => { + sink.next(result); + sink.complete(); + return () => {}; + }; + const client = mockClient(subscribe); + const link = new GraphQLWsLink(client); + + const obs = execute(link, { query: mutation }); + await expect(observableToArray(obs)).resolves.toEqual([result]); + }); + + it("should call next with multiple results for subscription", async () => { + const results = [ + { data: { data: "result1" } }, + { data: { data: "result2" } }, + ] as ExecutionResult[]; + const subscribe: Client["subscribe"] = (_, sink) => { + const copy = [...results]; + for (const r of copy) { + sink.next(r); + } + sink.complete(); + return () => {}; + }; + const client = mockClient(subscribe); + const link = new GraphQLWsLink(client); + + const obs = execute(link, { query: subscription }); + await expect(observableToArray(obs)).resolves.toEqual(results); + }); +}); diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts new file mode 100644 index 00000000000..a5953fd81f2 --- /dev/null +++ b/src/link/subscriptions/index.ts @@ -0,0 +1,77 @@ +// This file is adapted from sample code in the README of the graphql-ws npm package: +// https://github.com/enisdenjo/graphql-ws +// +// Here's the license of the original code: +// +// The MIT License (MIT) +// +// Copyright (c) 2020-2021 Denis Badurina +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import { print, GraphQLError } from "graphql"; +import { createClient, ClientOptions, Client } from "graphql-ws"; + +import { ApolloLink, Operation, FetchResult } from "../core"; +import { Observable } from "../../utilities"; + +export class GraphQLWsLink extends ApolloLink { + private client: Client; + + constructor(optionsOrClient: ClientOptions | Client) { + super(); + this.client = + // Client is an interface, not a class, so we can't use instanceof here + "subscribe" in optionsOrClient + ? optionsOrClient + : createClient(optionsOrClient); + } + + 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) { + return sink.error(err); + } + + if (err instanceof CloseEvent) { + return sink.error( + // reason will be available on clean closes + new Error( + `Socket closed with event ${err.code} ${err.reason || ""}` + ) + ); + } + + return sink.error( + new Error( + (err as GraphQLError[]).map(({ message }) => message).join(", ") + ) + ); + }, + } + ); + }); + } +} diff --git a/src/link/ws/__tests__/webSocketLink.ts b/src/link/ws/__tests__/webSocketLink.ts index 84bd09053cd..08eb413537f 100644 --- a/src/link/ws/__tests__/webSocketLink.ts +++ b/src/link/ws/__tests__/webSocketLink.ts @@ -88,7 +88,7 @@ describe('WebSocketLink', () => { client.request.mockReturnValueOnce(observable); const link = new WebSocketLink(client); - const obs = execute(link, { query: mutation }); + const obs = execute(link, { query: subscription }); expect(obs).toEqual(observable); obs.subscribe(data => { expect(data).toEqual(result); From a8ff09499c31cdc54dca737e239018cae54080ed Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 14:16:08 -0800 Subject: [PATCH 02/10] Update docs/source/api/link/apollo-link-ws.md Co-authored-by: Ben Newman --- docs/source/api/link/apollo-link-ws.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/api/link/apollo-link-ws.md b/docs/source/api/link/apollo-link-ws.md index 46162e5e452..0299bb56427 100644 --- a/docs/source/api/link/apollo-link-ws.md +++ b/docs/source/api/link/apollo-link-ws.md @@ -1,6 +1,6 @@ --- title: WebSocket Link -sidebar_title: WebSocket (newer protocol) +sidebar_title: WebSocket (older protocol) description: Execute subscriptions (or other GraphQL operations) over WebSocket with the `subscriptions-transport-ws` library api_reference: true --- From a573c8e1d50a02ffbe2993543d66429ab77c1cda Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 14:17:17 -0800 Subject: [PATCH 03/10] Update docs/source/data/subscriptions.mdx Co-authored-by: Ben Newman --- docs/source/data/subscriptions.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/data/subscriptions.mdx b/docs/source/data/subscriptions.mdx index 6216533a22d..15e60d9cdfc 100644 --- a/docs/source/data/subscriptions.mdx +++ b/docs/source/data/subscriptions.mdx @@ -328,7 +328,7 @@ The following is what typical `WebSocketLink` initialization looks like: ```js import { WebSocketLink } from "@apollo/client/link/ws"; -const wsLink = new GraphQLWsLink({ +const wsLink = new WebSocketLink({ uri: "ws://localhost:4000/subscriptions", options: { connectionParams: { From a10f48d15b0cfb5a8bb28230f67c9f7c8c62de8f Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 14:30:35 -0800 Subject: [PATCH 04/10] rename sink -> observer --- src/link/subscriptions/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts index a5953fd81f2..836dda872c6 100644 --- a/src/link/subscriptions/index.ts +++ b/src/link/subscriptions/index.ts @@ -44,19 +44,19 @@ export class GraphQLWsLink extends ApolloLink { } public request(operation: Operation): Observable { - return new Observable((sink) => { + return new Observable((observer) => { return this.client.subscribe( { ...operation, query: print(operation.query) }, { - next: sink.next.bind(sink), - complete: sink.complete.bind(sink), + next: observer.next.bind(observer), + complete: observer.complete.bind(observer), error: (err) => { if (err instanceof Error) { - return sink.error(err); + return observer.error(err); } if (err instanceof CloseEvent) { - return sink.error( + return observer.error( // reason will be available on clean closes new Error( `Socket closed with event ${err.code} ${err.reason || ""}` @@ -64,7 +64,7 @@ export class GraphQLWsLink extends ApolloLink { ); } - return sink.error( + return observer.error( new Error( (err as GraphQLError[]).map(({ message }) => message).join(", ") ) From a1fcb6cafeeab8e65e36323e9d07dd887a8c04c8 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 14:35:15 -0800 Subject: [PATCH 05/10] Use ApolloError --- src/link/subscriptions/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts index 836dda872c6..0c11c31ac27 100644 --- a/src/link/subscriptions/index.ts +++ b/src/link/subscriptions/index.ts @@ -25,11 +25,12 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import { print, GraphQLError } from "graphql"; +import { print } from "graphql"; import { createClient, ClientOptions, Client } from "graphql-ws"; import { ApolloLink, Operation, FetchResult } from "../core"; import { Observable } from "../../utilities"; +import { ApolloError } from "../../errors"; export class GraphQLWsLink extends ApolloLink { private client: Client; @@ -65,9 +66,9 @@ export class GraphQLWsLink extends ApolloLink { } return observer.error( - new Error( - (err as GraphQLError[]).map(({ message }) => message).join(", ") - ) + new ApolloError({ + graphQLErrors: Array.isArray(err) ? err : [err], + }) ); }, } From b453cedeb35b6d00e1c9abe49409f4c46f48d96c Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 14:44:14 -0800 Subject: [PATCH 06/10] Don't accept createClient options; just accept a Client --- .../api/link/apollo-link-subscriptions.md | 13 +++--- docs/source/data/subscriptions.mdx | 40 +++++++++++-------- src/link/subscriptions/index.ts | 11 +---- 3 files changed, 31 insertions(+), 33 deletions(-) diff --git a/docs/source/api/link/apollo-link-subscriptions.md b/docs/source/api/link/apollo-link-subscriptions.md index 81df581dffd..3060e2a7ca5 100644 --- a/docs/source/api/link/apollo-link-subscriptions.md +++ b/docs/source/api/link/apollo-link-subscriptions.md @@ -21,21 +21,18 @@ npm install graphql-ws ```js import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; +import { createClient } from "graphql-ws"; -const link = new GraphQLWsLink({ +const link = new GraphQLWsLink(createClient({ url: "ws://localhost:3000/subscriptions", -}); +})); ``` ### Options -The `GraphQLWsLink` constructor takes a single object. This can either be a `Client` returned from the `graphql-ws` `createClient` function, or an options object that will be passed directly to the `createClient` function. +The `GraphQLWsLink` constructor takes a single argument, which is a `Client` returned from the `graphql-ws` `createClient` function. -If you are passing an options object, the one required option is `url`, which is the URL (typically starting with `ws://` or `wss://`, which are the equivalents of `http://` and `https://` respectively) to your WebSocket server. - -Full documentation of supported options can be found in [the `graphql-ws` docs for `ClientOptions`](https://github.com/enisdenjo/graphql-ws/blob/master/docs/interfaces/client.ClientOptions.md). - -
+The `createClient` function can take many options; full details can be found in [the `graphql-ws` docs for `ClientOptions`](https://github.com/enisdenjo/graphql-ws/blob/master/docs/interfaces/client.ClientOptions.md). The one required option is `url`, which is the URL (typically starting with `ws://` or `wss://`, which are the equivalents of `http://` and `https://` respectively) to your WebSocket server. (Note that this differs from the [older link's URL option](./apollo-link-ws) which is called `uri` rather than `url`.) ## Usage diff --git a/docs/source/data/subscriptions.mdx b/docs/source/data/subscriptions.mdx index 15e60d9cdfc..63dfadce663 100644 --- a/docs/source/data/subscriptions.mdx +++ b/docs/source/data/subscriptions.mdx @@ -96,10 +96,11 @@ Import and initialize a `GraphQLWsLink` object in the same project file where yo ```js:title=index.js import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { createClient } from 'graphql-ws'; -const wsLink = new GraphQLWsLink({ +const wsLink = new GraphQLWsLink(createClient({ url: 'ws://localhost:4000/subscriptions', -}); +})); ``` Replace the value of the `url` option with your GraphQL server's subscription-specific WebSocket endpoint. If you're using Apollo Server, see [Setting a subscription endpoint](https://www.apollographql.com/docs/apollo-server/data/subscriptions/#setting-a-subscription-endpoint). @@ -116,14 +117,15 @@ The following example expands on the previous one by initializing both a `GraphQ import { split, HttpLink } from '@apollo/client'; import { getMainDefinition } from '@apollo/client/utilities'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { createClient } from 'graphql-ws'; const httpLink = new HttpLink({ uri: 'http://localhost:4000/graphql' }); -const wsLink = new GraphQLWsLink({ +const wsLink = new GraphQLWsLink(createClient({ url: 'ws://localhost:4000/subscriptions', -}); +})); // The split function takes three parameters: // @@ -166,15 +168,16 @@ const client = new ApolloClient({ It is often necessary to authenticate a client before allowing it to receive subscription results. To do this, you can provide a `connectionParams` option to the `GraphQLWsLink` constructor, like so: -```js{7-9} +```js{6-8} import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { createClient } from 'graphql-ws'; -const wsLink = new GraphQLWsLink({ +const wsLink = new GraphQLWsLink(createClient({ url: 'ws://localhost:4000/subscriptions', connectionParams: { authToken: user.authToken, }, -}); +})); ``` Your `GraphQLWsLink` passes the `connectionParams` object to your server whenever it connects. Your server receives the `connectionParams` object and can use it to perform authentication, along with any other connection-related tasks. @@ -315,11 +318,13 @@ After being called, the `useSubscription` Hook returns a result object with the If your server uses `subscriptions-transport-ws` instead of the newer `graphql-ws` library, you need to make a few changes to how you set up your link. -Instead of `npm install graphql-ws`, you `npm install subscriptions-transport-ws` +Instead of `npm install graphql-ws`, you `npm install subscriptions-transport-ws`. + +Instead of `import { createClient } from 'graphql-ws'`, you `import { SubscriptionClient } from 'subscriptions-transport-ws'`. Instead of `import { GraphQLWsLink } from '@apollo/client/link/subscriptions'`, you `import { WebSocketLink } from '@apollo/client/link/ws`. -The options passed to the link constructor are slightly different. The subscriptions URL is specified in an `uri` option instead of an `url` option. The `connectionParams` option is nested under an options object called `options` instead of being at the top level. +The options passed to `new SubscriptionClient` are slightly different from those passed to `createClient`. The subscriptions URL is specified in an `uri` option instead of an `url` option. The `connectionParams` option is nested under an options object called `options` instead of being at the top level. (You may also pass the `new SubscriptionClient` constructor arguments directly to `new WebSocketLink`.) Once you've created your `wsLink`, everything else in this document still applies: `useSubscription`, `subscribeToMore`, and split links work exactly the same way for both implementations. @@ -327,14 +332,17 @@ The following is what typical `WebSocketLink` initialization looks like: ```js import { WebSocketLink } from "@apollo/client/link/ws"; - -const wsLink = new WebSocketLink({ - uri: "ws://localhost:4000/subscriptions", - options: { - connectionParams: { - authToken: user.authToken, +import { SubscriptionClient } from "subscriptions-transport-ws"; + +const wsLink = new WebSocketLink( + new SubscriptionClient({ + uri: "ws://localhost:4000/subscriptions", + options: { + connectionParams: { + authToken: user.authToken, + }, }, - }, + }), }); ``` diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts index 0c11c31ac27..203f30ff750 100644 --- a/src/link/subscriptions/index.ts +++ b/src/link/subscriptions/index.ts @@ -26,22 +26,15 @@ // THE SOFTWARE. import { print } from "graphql"; -import { createClient, ClientOptions, Client } from "graphql-ws"; +import type { Client } from "graphql-ws"; import { ApolloLink, Operation, FetchResult } from "../core"; import { Observable } from "../../utilities"; import { ApolloError } from "../../errors"; export class GraphQLWsLink extends ApolloLink { - private client: Client; - - constructor(optionsOrClient: ClientOptions | Client) { + constructor(public readonly client: Client) { super(); - this.client = - // Client is an interface, not a class, so we can't use instanceof here - "subscribe" in optionsOrClient - ? optionsOrClient - : createClient(optionsOrClient); } public request(operation: Operation): Observable { From a3604e40f3473d10999fd327fd199e37091b09ec Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 14:48:00 -0800 Subject: [PATCH 07/10] Document old link preferring explicit SubscriptionClient --- docs/source/api/link/apollo-link-ws.md | 17 ++++++++++------- docs/source/data/subscriptions.mdx | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/source/api/link/apollo-link-ws.md b/docs/source/api/link/apollo-link-ws.md index 0299bb56427..c7c87c5c775 100644 --- a/docs/source/api/link/apollo-link-ws.md +++ b/docs/source/api/link/apollo-link-ws.md @@ -21,18 +21,21 @@ npm install subscriptions-transport-ws ```js import { WebSocketLink } from "@apollo/client/link/ws"; - -const link = new WebSocketLink({ - uri: "ws://localhost:3000/subscriptions", - options: { - reconnect: true, - }, +import { SubscriptionClient } from "subscriptions-transport-ws"; + +const link = new WebSocketLink( + new SubscriptionClient({ + uri: "ws://localhost:3000/subscriptions", + options: { + reconnect: true, + }, + }), }); ``` ### Options -The `WebSocketLink` constructor takes an options object with the following fields: +The `WebSocketLink` constructor takes either a `SubscriptionClient` object or an options object with the following fields. (These options are passed directly to the `SubscriptionClient` constructor.)
diff --git a/docs/source/data/subscriptions.mdx b/docs/source/data/subscriptions.mdx index 63dfadce663..0a5ff91751f 100644 --- a/docs/source/data/subscriptions.mdx +++ b/docs/source/data/subscriptions.mdx @@ -324,7 +324,7 @@ Instead of `import { createClient } from 'graphql-ws'`, you `import { Subscripti Instead of `import { GraphQLWsLink } from '@apollo/client/link/subscriptions'`, you `import { WebSocketLink } from '@apollo/client/link/ws`. -The options passed to `new SubscriptionClient` are slightly different from those passed to `createClient`. The subscriptions URL is specified in an `uri` option instead of an `url` option. The `connectionParams` option is nested under an options object called `options` instead of being at the top level. (You may also pass the `new SubscriptionClient` constructor arguments directly to `new WebSocketLink`.) +The options passed to `new SubscriptionClient` are slightly different from those passed to `createClient`. The subscriptions URL is specified in an `uri` option instead of an `url` option. The `connectionParams` option is nested under an options object called `options` instead of being at the top level. (You may also pass the `new SubscriptionClient` constructor arguments directly to `new WebSocketLink`.) See [the `subscriptions-transport-ws` README](https://www.npmjs.com/package/subscriptions-transport-ws) for complete `SubscriptionClient` API docs. Once you've created your `wsLink`, everything else in this document still applies: `useSubscription`, `subscribeToMore`, and split links work exactly the same way for both implementations. From b344f00f18ab14015a3583b976ddbcdcf8d59961 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 14:50:40 -0800 Subject: [PATCH 08/10] Adopt isLikeCloseEvent from graphql-ws --- src/link/subscriptions/index.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts index 203f30ff750..636721717f8 100644 --- a/src/link/subscriptions/index.ts +++ b/src/link/subscriptions/index.ts @@ -1,6 +1,9 @@ -// This file is adapted from sample code in the README of the graphql-ws npm package: +// This file is adapted from the graphql-ws npm package: // https://github.com/enisdenjo/graphql-ws // +// Most of the file comes from that package's README; some other parts (such as +// isLikeCloseEvent) come from its source. +// // Here's the license of the original code: // // The MIT License (MIT) @@ -32,6 +35,22 @@ import { ApolloLink, Operation, FetchResult } from "../core"; import { Observable } from "../../utilities"; import { ApolloError } from "../../errors"; + +function isObject(val: unknown): val is Record { + return typeof val === 'object' && val !== null; +} +interface LikeCloseEvent { + /** Returns the WebSocket connection close code provided by the server. */ + readonly code: number; + /** Returns the WebSocket connection close reason provided by the server. */ + readonly reason: string; +} + +function isLikeCloseEvent(val: unknown): val is LikeCloseEvent { + return isObject(val) && 'code' in val && 'reason' in val; +} + + export class GraphQLWsLink extends ApolloLink { constructor(public readonly client: Client) { super(); @@ -49,7 +68,7 @@ export class GraphQLWsLink extends ApolloLink { return observer.error(err); } - if (err instanceof CloseEvent) { + if (isLikeCloseEvent(err)) { return observer.error( // reason will be available on clean closes new Error( From 11c005124e839eb021b68fdb1287e7173262b94d Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 15:10:12 -0800 Subject: [PATCH 09/10] Use AC isNonNullObject instead of graphql-ws copy-paste version --- src/link/subscriptions/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/link/subscriptions/index.ts b/src/link/subscriptions/index.ts index 636721717f8..198948bc3a9 100644 --- a/src/link/subscriptions/index.ts +++ b/src/link/subscriptions/index.ts @@ -32,13 +32,9 @@ import { print } from "graphql"; import type { Client } from "graphql-ws"; import { ApolloLink, Operation, FetchResult } from "../core"; -import { Observable } from "../../utilities"; +import { isNonNullObject, Observable } from "../../utilities"; import { ApolloError } from "../../errors"; - -function isObject(val: unknown): val is Record { - return typeof val === 'object' && val !== null; -} interface LikeCloseEvent { /** Returns the WebSocket connection close code provided by the server. */ readonly code: number; @@ -47,7 +43,7 @@ interface LikeCloseEvent { } function isLikeCloseEvent(val: unknown): val is LikeCloseEvent { - return isObject(val) && 'code' in val && 'reason' in val; + return isNonNullObject(val) && 'code' in val && 'reason' in val; } From 4caf088663fd5a70e5662804de5a4f1612abd4fa Mon Sep 17 00:00:00 2001 From: David Glasser Date: Tue, 1 Feb 2022 15:33:13 -0800 Subject: [PATCH 10/10] Add CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b59d0ab8b3..e72d79890ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Tentatively reimplement `useQuery` and `useLazyQuery` to use the [proposed `useSyncExternalStore` API](https://github.com/reactwg/react-18/discussions/86) from React 18.
[@brainkim](https://github.com/brainkim) in [#8785](https://github.com/apollographql/apollo-client/pull/8785) +- Add `GraphQLWsLink` in `@apollo/client/ws/subscriptions`. This link is similar to the existing `WebSocketLink` in `@apollo/client/link/ws`, but uses the newer [`graphql-ws`](https://www.npmjs.com/package/graphql-ws) package and protocol instead of the older `subscriptions-transport-ws` implementation. + [@glasser](https://github.com/glasser) in [#9369](https://github.com/apollographql/apollo-client/pull/9369) ## Apollo Client 3.5.7 (2022-01-10)