Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server onDisconnect event #91

Closed
MartijnHols opened this issue Jan 2, 2021 · 10 comments · Fixed by #94
Closed

Server onDisconnect event #91

MartijnHols opened this issue Jan 2, 2021 · 10 comments · Fixed by #94
Labels
enhancement New feature or request released Has been released and published

Comments

@MartijnHols
Copy link

MartijnHols commented Jan 2, 2021

Story

As a server I want to track the amount of open websocket connections and update the user's online status when a user disconnects so that I can monitor and analyze the health of my server and update a user's online status immediately when the browser is closed.

Acceptance criteria

  • server can add a disconnect event listener
  • server's disconnect event listener passes either the connection's context or the ctx's extra with request to be able to determine the user

Example use case

import client from 'prom-client'

const webSocketTotalConnectionsCounter = new client.Counter({
  name: 'websocket_connections_total',
  help: 'The total number of Apollo WebSocket connections handled so far.',
})
const webSocketOpenConnectionsGauge = new client.Gauge({
  name: 'websocket_connections_open',
  help: 'The number of open Apollo WebSocket connections.',
})

useServer(
  {
    schema,
    execute,
    subscribe,
    async onConnect(ctx) {
      webSocketTotalConnectionsCounter.inc()
      webSocketOpenConnectionsGauge.inc()

      await setOnline(ctx)
    },
    async onDisconnect(ctx) {
      webSocketOpenConnectionsGauge.dec()

      await setOffline(ctx)
    },
  },
  wsServer,
)
@brettjashford
Copy link

i have yet to switch from subscriptions-transport-ws, but an onDisconnect server callback would be helpful. currently use this to send stats to datadog on the number of connected clients

@enisdenjo enisdenjo added enhancement New feature or request question Further information about the library is requested and removed enhancement New feature or request labels Jan 11, 2021
@enisdenjo
Copy link
Owner

enisdenjo commented Jan 11, 2021

The thing is, the actual "server" is just a connection controller which can be hooked up to any WebSocket implementation. Its meant to be ultra minimal and its sole purpose is marshalling the messages and having high level control over the channel. Because of this, it does not have a concept of a "disconnect" - it simply does not care. So, ultimately, there is no place for a onDisconnect in the controller.

However, you can indeed achieve this by providing a custom implementation over ws using the raw import { makeServer } from 'graphql-ws' or by simply hooking up on the onclose event in the ws.Server instance. You can check how I implement ws here:

graphql-ws/src/use/ws.ts

Lines 25 to 126 in 5483fac

/**
* Use the server on a [ws](https://github.com/websockets/ws) ws server.
* This is a basic starter, feel free to copy the code over and adjust it to your needs
*/
export function useServer(
options: ServerOptions<Extra>,
ws: WebSocketServer,
/**
* The timout between dispatched keep-alive messages. Internally uses the [ws Ping and Pongs]((https://developer.mozilla.org/en-US/docs/Web/API/wss_API/Writing_ws_servers#Pings_and_Pongs_The_Heartbeat_of_wss))
* to check that the link between the clients and the server is operating and to prevent the link
* from being broken due to idling.
*
* @default 12 * 1000 // 12 seconds
*/
keepAlive = 12 * 1000,
): Disposable {
const isProd = process.env.NODE_ENV === 'production';
const server = makeServer<Extra>(options);
ws.on('error', (err) => {
// catch the first thrown error and re-throw it once all clients have been notified
let firstErr: Error | null = null;
// report server errors by erroring out all clients with the same error
for (const client of ws.clients) {
try {
client.close(1011, isProd ? 'Internal Error' : err.message);
} catch (err) {
firstErr = firstErr ?? err;
}
}
if (firstErr) {
throw firstErr;
}
});
ws.on('connection', (socket, request) => {
// keep alive through ping-pong messages
let pongWait: NodeJS.Timeout | null = null;
const pingInterval =
keepAlive > 0 && isFinite(keepAlive)
? setInterval(() => {
// ping pong on open sockets only
if (socket.readyState === socket.OPEN) {
// terminate the connection after pong wait has passed because the client is idle
pongWait = setTimeout(() => {
socket.terminate();
}, keepAlive);
// listen for client's pong and stop socket termination
socket.once('pong', () => {
if (pongWait) {
clearTimeout(pongWait);
pongWait = null;
}
});
socket.ping();
}
}, keepAlive)
: null;
const closed = server.opened(
{
protocol: socket.protocol,
send: (data) =>
new Promise((resolve, reject) => {
socket.send(data, (err) => (err ? reject(err) : resolve()));
}),
close: (code, reason) => socket.close(code, reason),
onMessage: (cb) =>
socket.on('message', async (event) => {
try {
await cb(event.toString());
} catch (err) {
socket.close(1011, isProd ? 'Internal Error' : err.message);
}
}),
},
{ socket, request },
);
socket.once('close', () => {
if (pongWait) clearTimeout(pongWait);
if (pingInterval) clearInterval(pingInterval);
closed();
});
});
return {
dispose: async () => {
for (const client of ws.clients) {
client.close(1001, 'Going away');
}
ws.removeAllListeners();
await new Promise<void>((resolve, reject) => {
ws.close((err) => (err ? reject(err) : resolve()));
});
},
};
}

If you need additional help, I'll write up an usage example for you!

@enisdenjo
Copy link
Owner

I spoke too soon... PR coming soon!

@enisdenjo enisdenjo added enhancement New feature or request and removed question Further information about the library is requested labels Jan 12, 2021
@enisdenjo
Copy link
Owner

🎉 This issue has been resolved in version 4.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@enisdenjo enisdenjo added the released Has been released and published label Jan 13, 2021
@enisdenjo
Copy link
Owner

enisdenjo commented Jan 13, 2021

Hey @MartijnHols and @brettjashford, I've added 2 new callbacks: onDisconnect and onClose. Both are used for tracking socket closures; however, they are a bit different:

onDisconnect

Called exclusively after the connection has been acknowledged (the client successfully went through the connection initialisation phase). Meaning, you can chain the onConnect + onDisconnect to track actual, compatible, clients. onConnect will always be called before onDisconnect. Is called before onClose.

onClose

Called when the socket closes at any point in time. It does not care if the connection has been acknowledged or not. The onConnect callback must not necessarily be called before it. Is called after onDisconnect.

@acro5piano
Copy link

acro5piano commented Apr 8, 2021

Hi @enisdenjo thank you for the amazing library!

I would like to implement the same feature of what @MartijnHols said.

update the user's online status when a user disconnects

The code should be like this, right?

import { useServer } from 'graphql-ws/lib/use/ws';
import { db } from './my-db-service'

useServer({
  context: (ctx) => ({
    userId: ctx.connectionParams.userId // psuedo code, no auth logic
  }),
  onDisconnect: (ctx) => {
    db.update({ status: 'offline' }).where({ userId: ctx.connectionParams.userId })
  }
}, ws )

@enisdenjo
Copy link
Owner

Hey there @acro5piano! I'd recommend reading #91 (comment) to understand the difference between onClose and onDisconnect.

In essence, onDisconnect is called only if the user successfully went through the connection phase (WebSocket established + ConnectionAck message dispatched). On the other hand, onClose is called regardless if the user successfully connected or not - so, every time the socket closes. The call order when the WebSocket closes is:

  1. if acknowledged then onDisconnect
  2. onClose

@acro5piano
Copy link

@enisdenjo
Thank you for your quick response! So onClose seems better because it is possible that user disconnects without sending signals, e.g.) internet disconnection.

@enisdenjo
Copy link
Owner

If the user disconnects abruptly without graphql-ws acknowledging the connection, only onClose will be called. So, yeah, I'd suggest using onClose in your case.

@acro5piano
Copy link

@enisdenjo Thanks for your help!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request released Has been released and published
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants