Skip to content

Commit

Permalink
feat: Bidirectional ping/pong message types (enisdenjo#201)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: Because of the Protocol's strictness, an instant connection termination will happen whenever an invalid message is identified; meaning, all previous implementations will fail when receiving the new subprotocol ping/pong messages.

**Beware,** the client will NOT ping the server by default. Please make sure to upgrade your stack in order to support the new ping/pong message types.

A simple recipe showcasing a client that times out if no pong is received and measures latency, looks like this:
```js
import { createClient } from 'graphql-ws';

let activeSocket,
  timedOut,
  pingSentAt = 0,
  latency = 0;
createClient({
  url: 'ws://i.time.out:4000/and-measure/latency',
  keepAlive: 10_000, // ping server every 10 seconds
  on: {
    connected: (socket) => (activeSocket = socket),
    ping: (received) => {
      if (!received /* sent */) {
        pingSentAt = Date.now();
        timedOut = setTimeout(() => {
          if (activeSocket.readyState === WebSocket.OPEN)
            activeSocket.close(4408, 'Request Timeout');
        }, 5_000); // wait 5 seconds for the pong and then close the connection
      }
    },
    pong: (received) => {
      if (received) {
        latency = Date.now() - pingSentAt;
        clearTimeout(timedOut); // pong is received, clear connection close timeout
      }
    },
  },
});
```
  • Loading branch information
enisdenjo authored Jun 8, 2021
1 parent 02ea5ee commit 1efaf83
Show file tree
Hide file tree
Showing 14 changed files with 562 additions and 124 deletions.
30 changes: 30 additions & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ interface ConnectionAckMessage {

The client is now **ready** to request subscription operations.

### `Ping`

Direction: **bidirectional**

Useful for detecting failed connections, displaying latency metrics or other types of network probing.

A `Pong` must be sent in response from the receiving party as soon as possible.

The `Ping` message can be sent at any time within the established socket.

```typescript
interface PingMessage {
type: 'ping';
}
```

### `Pong`

Direction: **bidirectional**

The response to the `Ping` message. Must be sent as soon as the `Ping` message is received.

The `Pong` message can be sent at any time within the established socket. Furthermore, the `Pong` message may even be sent unsolicited as an unidirectional heartbeat.

```typescript
interface PongMessage {
type: 'pong';
}
```

### `Subscribe`

Direction: **Client -> Server**
Expand Down
154 changes: 36 additions & 118 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,42 @@ const client = createRestartableClient({

</details>

<details id="ping-from-client">
<summary><a href="#ping-from-client">🔗</a> Client usage with ping/pong timeout and latency metrics</summary>

```typescript
import { createClient } from 'graphql-ws';

let activeSocket,
timedOut,
pingSentAt = 0,
latency = 0;
createClient({
url: 'ws://i.time.out:4000/and-measure/latency',
keepAlive: 10_000, // ping server every 10 seconds
on: {
connected: (socket) => (activeSocket = socket),
ping: (received) => {
if (!received /* sent */) {
pingSentAt = Date.now();
timedOut = setTimeout(() => {
if (activeSocket.readyState === WebSocket.OPEN)
activeSocket.close(4408, 'Request Timeout');
}, 5_000); // wait 5 seconds for the pong and then close the connection
}
},
pong: (received) => {
if (received) {
latency = Date.now() - pingSentAt;
clearTimeout(timedOut); // pong is received, clear connection close timeout
}
},
},
});
```

</details>

<details id="browser">
<summary><a href="#browser">🔗</a> Client usage in browser</summary>

Expand Down Expand Up @@ -1267,124 +1303,6 @@ const client = createClient({

</details>

<details id="ping-from-client">
<summary><a href="#ping-from-client">🔗</a> <a href="https://github.com/websockets/ws">ws</a> server and client with client to server pings and latency</summary>

```typescript
// 🛸 server

import {
GraphQLSchema,
GraphQLObjectType,
GraphQLNonNull,
GraphQLString,
} from 'graphql';
import ws from 'ws'; // yarn add ws
import { useServer } from 'graphql-ws/lib/use/ws';
import { schema } from './my-graphql-schema';

// a custom graphql schema that holds just the ping query.
// used exclusively when the client sends a ping to the server.
// if you want to send/receive more details, simply adjust the pinger schema.
const pinger = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
ping: {
type: new GraphQLNonNull(GraphQLString),
resolve: () => 'pong',
},
},
}),
});

const wsServer = new WebSocket.Server({
port: 4000,
path: '/graphql',
});

useServer(
{
schema: (_ctx, msg) => {
if (msg.payload.query === '{ ping }') return pinger;
return schema;
},
},
wsServer,
);
```

```typescript
// 📺 client

import { createClient } from 'graphql-ws';

let connection: WebSocket | undefined;
const client = createClient({
url: 'ws://client.can:4000/send-pings/too',
on: {
connected: (socket) => (connection = socket as WebSocket),
closed: () => (connection = undefined),
},
});

async function ping() {
// record the ping sent at moment for calculating latency
const pinged = Date.now();

// if the client went offline or the server is unresponsive
// close the active WebSocket connection as soon as the pong
// wait timeout expires and have the client silently reconnect.
// there is no need to dispose of the subscription since it
// will eventually settle because either:
// - the client reconnected and a new pong is received
// - the retry attempts were exceeded and the close is reported
// because if this, the latency accounts for retry waits too.
// if you do not want this, simply dispose of the ping subscription
// as soon as the pong timeout is exceeded
const pongTimeout = setTimeout(
() => connection?.close(4408, 'Pong Timeout'),
2000, // expect a pong within 2 seconds of the ping
);

// wait for the pong. the promise is guaranteed to settle
await new Promise<void>((resolve, reject) => {
client.subscribe<{ data: { ping: string } }>(
{ query: '{ ping }' },
{
next: () => {
/* not interested in the pong */
},
error: reject,
complete: resolve,
},
);
// whatever happens to the promise, clear the pong timeout
}).finally(() => clearTimeout(pongTimeout));

// record when pong has been received
const ponged = Date.now();

// how long it took for the pong to arrive after sending the ping
return ponged - pinged;
}

// keep pinging until a fatal problem occurs
(async () => {
for (;;) {
const latency = await ping();

// or send to your favourite logger - the user
console.info('GraphQL WebSocket connection latency', latency);

// ping every 3 seconds
await new Promise((resolve) => setTimeout(resolve, 3000));
}
})();
```

</details>

<details id="auth-token">
<summary><a href="#auth-token">🔗</a> <a href="https://github.com/websockets/ws">ws</a> server and client auth usage with token expiration, validation and refresh</summary>

Expand Down
14 changes: 14 additions & 0 deletions docs/enums/common.messagetype.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Types of messages allowed to be sent by the client/server over the WS protocol.
- [ConnectionInit](common.messagetype.md#connectioninit)
- [Error](common.messagetype.md#error)
- [Next](common.messagetype.md#next)
- [Ping](common.messagetype.md#ping)
- [Pong](common.messagetype.md#pong)
- [Subscribe](common.messagetype.md#subscribe)

## Enumeration members
Expand Down Expand Up @@ -49,6 +51,18 @@ ___

___

### Ping

**Ping** = "ping"

___

### Pong

**Pong** = "pong"

___

### Subscribe

**Subscribe** = "subscribe"
43 changes: 43 additions & 0 deletions docs/interfaces/client.clientoptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Configuration used for the GraphQL over WebSocket client.
- [isFatalConnectionProblem](client.clientoptions.md#isfatalconnectionproblem)
- [jsonMessageReplacer](client.clientoptions.md#jsonmessagereplacer)
- [jsonMessageReviver](client.clientoptions.md#jsonmessagereviver)
- [keepAlive](client.clientoptions.md#keepalive)
- [lazy](client.clientoptions.md#lazy)
- [lazyCloseTimeout](client.clientoptions.md#lazyclosetimeout)
- [on](client.clientoptions.md#on)
Expand Down Expand Up @@ -118,6 +119,48 @@ out of the incoming JSON.

___

### keepAlive

`Optional` **keepAlive**: `number`

The timout between dispatched keep-alive messages, naimly server pings. Internally
dispatches the `PingMessage` type to the server and expects a `PongMessage` in response.
This helps with making sure that the connection with the server is alive and working.

Timeout countdown starts from the moment the socket was opened and subsequently
after every received `PongMessage`.

Note that NOTHING will happen automatically with the client if the server never
responds to a `PingMessage` with a `PongMessage`. If you want the connection to close,
you should implement your own logic on top of the client. A simple example looks like this:

```js
import { createClient } from 'graphql-ws';

let activeSocket, timedOut;
createClient({
url: 'ws://i.time.out:4000/after-5/seconds',
keepAlive: 10_000, // ping server every 10 seconds
on: {
connected: (socket) => (activeSocket = socket),
ping: (received) => {
if (!received) // sent
timedOut = setTimeout(() => {
if (activeSocket.readyState === WebSocket.OPEN)
activeSocket.close(4408, 'Request Timeout');
}, 5_000); // wait 5 seconds for the pong and then close the connection
},
pong: (received) => {
if (received) clearTimeout(timedOut); // pong is received, clear connection close timeout
},
},
});
```

**`default`** 0

___

### lazy

`Optional` **lazy**: `boolean`
Expand Down
17 changes: 17 additions & 0 deletions docs/interfaces/common.pingmessage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[graphql-ws](../README.md) / [common](../modules/common.md) / PingMessage

# Interface: PingMessage

[common](../modules/common.md).PingMessage

## Table of contents

### Properties

- [type](common.pingmessage.md#type)

## Properties

### type

`Readonly` **type**: [Ping](../enums/common.messagetype.md#ping)
17 changes: 17 additions & 0 deletions docs/interfaces/common.pongmessage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[graphql-ws](../README.md) / [common](../modules/common.md) / PongMessage

# Interface: PongMessage

[common](../modules/common.md).PongMessage

## Table of contents

### Properties

- [type](common.pongmessage.md#type)

## Properties

### type

`Readonly` **type**: [Pong](../enums/common.messagetype.md#pong)
Loading

0 comments on commit 1efaf83

Please sign in to comment.