diff --git a/.changeset/socket-mode-hello-event.md b/.changeset/socket-mode-hello-event.md new file mode 100644 index 000000000..32a8228ca --- /dev/null +++ b/.changeset/socket-mode-hello-event.md @@ -0,0 +1,5 @@ +--- +"@slack/socket-mode": minor +--- + +feat(socket-mode): emit a `hello` event with the connection handshake payload, exposing `num_connections`, `debug_info`, and `connection_info` diff --git a/packages/socket-mode/README.md b/packages/socket-mode/README.md index aad663361..ef4a1f46e 100644 --- a/packages/socket-mode/README.md +++ b/packages/socket-mode/README.md @@ -140,8 +140,21 @@ The client also emits events that are part of its lifecycle, but aren't states. | Event Name | Arguments | Description | |-----------------|-----------|-------------| | `error` | `(error)` | An error has occurred. | +| `hello` | `(message)` | Slack has finalized the connection handshake. Emitted just before `connected`. | | `slack_event` | `(eventType, event)` | An incoming Slack event has been received. | +The `hello` message reports how many connections are currently open for your app-level token, which is one way to notice that a second instance of your app is running: + +```javascript +socketModeClient.on('hello', ({ num_connections }) => { + if (num_connections > 1) { + console.warn(`This app token has ${num_connections} open connections.`); + } +}); +``` + +It also carries `debug_info` and `connection_info`, including `debug_info.approximate_connection_time`, an estimate in seconds of how long Slack will keep this connection before refreshing it. + --- ### Handle errors diff --git a/packages/socket-mode/src/HelloMessage.ts b/packages/socket-mode/src/HelloMessage.ts new file mode 100644 index 000000000..66046247d --- /dev/null +++ b/packages/socket-mode/src/HelloMessage.ts @@ -0,0 +1,41 @@ +/** + * @description The `hello` message Slack sends over the WebSocket once it has finalized the Socket Mode handshake. It + * reports how many connections the app-level token currently holds open, which is handy for detecting that more than + * one instance of an app is running. + * @see {@link https://docs.slack.dev/apis/events-api/using-socket-mode Using Socket Mode}. + */ +export interface HelloMessage { + /** @description The type of message. For a hello message, `type` is always `hello`. */ + type: 'hello'; + /** @description The number of connections currently open for the app-level token used by this client. */ + num_connections: number; + /** @description Diagnostic details about the Slack host serving this connection. */ + debug_info?: HelloMessageDebugInfo; + /** @description Details about the app this connection belongs to. */ + connection_info?: HelloMessageConnectionInfo; +} + +/** + * @description Diagnostic details about the Slack host serving a Socket Mode connection. + */ +export interface HelloMessageDebugInfo { + /** @description The Slack host serving this connection. */ + host?: string; + /** @description When the connection was established. */ + started?: string; + /** @description The build serving this connection. */ + build_number?: number; + /** + * @description How long, in seconds, the connection is expected to persist before Slack refreshes it. Useful for + * estimating when the next reconnect will happen. + */ + approximate_connection_time?: number; +} + +/** + * @description Details about the app a Socket Mode connection was opened for. + */ +export interface HelloMessageConnectionInfo { + /** @description The ID of the app this connection belongs to. */ + app_id?: string; +} diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 368644556..d6f93d665 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -5,6 +5,7 @@ import type { FetchFunction } from '@slack/web-api'; import proxyquire from 'proxyquire'; import sinon from 'sinon'; +import type { HelloMessage } from './HelloMessage'; import logModule from './logger'; import { SocketModeClient } from './SocketModeClient'; import type { SocketModeDispatcher } from './SocketModeOptions'; @@ -85,6 +86,54 @@ describe('SocketModeClient', () => { describe('onWebSocketMessage', () => { // While this method is protected and cannot be invoked directly, emitting the 'message' event directly invokes it + describe('hello messages', () => { + const message = JSON.stringify({ + type: 'hello', + num_connections: 2, + debug_info: { + host: 'applink-1', + build_number: 111, + approximate_connection_time: 18060, + }, + connection_info: { app_id: 'A111' }, + }); + + it('should pass the hello payload to hello listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let helloArgs: HelloMessage | undefined; + client.on('hello', (args: HelloMessage) => { + helloArgs = args; + }); + client.emit('ws_message', message, false /* isBinary */); + await sleep(30); + assert.deepStrictEqual(helloArgs, JSON.parse(message)); + }); + + it('should emit hello before transitioning to the connected state', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + const observed: string[] = []; + client.on('hello', () => { + observed.push('hello'); + }); + client.on('connected', () => { + observed.push('connected'); + }); + client.emit('ws_message', message, false /* isBinary */); + await sleep(30); + assert.deepStrictEqual(observed, ['hello', 'connected']); + }); + + it('should not be sent to slack_event listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let slackEventListenerCalled = false; + client.on('slack_event', async () => { + slackEventListenerCalled = true; + }); + client.emit('ws_message', message, false /* isBinary */); + await sleep(30); + assert.strictEqual(slackEventListenerCalled, false); + }); + }); describe('slash_commands messages', () => { const envelopeId = '1d3c79ab-0ffb-41f3-a080-d19e85f53649'; const message = JSON.stringify({ diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 9f07c58c4..9fda95084 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -13,6 +13,7 @@ import { type RequestInit, fetch as undiciFetch } from 'undici'; import packageJson from '../package.json'; import { SMSendWhileDisconnectedError, SMSendWhileNotReadyError, SMWebsocketError } from './errors'; +import type { HelloMessage } from './HelloMessage'; import log, { type Logger, LogLevel } from './logger'; import { SlackWebSocket, WS_READY_STATES } from './SlackWebSocket'; import type { SocketModeDispatcher, SocketModeOptions } from './SocketModeOptions'; @@ -336,6 +337,9 @@ export class SocketModeClient extends EventEmitter { // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { + // Emitted ahead of the connected state, since `start()` resolves on that state and listeners would otherwise + // miss the handshake details of the first connection. + this.emit('hello', event as unknown as HelloMessage); this.emit(State.Connected, this.connectionResponse); return; } diff --git a/packages/socket-mode/src/index.ts b/packages/socket-mode/src/index.ts index 05ed0ec2a..6aa3d859b 100644 --- a/packages/socket-mode/src/index.ts +++ b/packages/socket-mode/src/index.ts @@ -12,6 +12,7 @@ export { SMWebsocketError, } from './errors'; +export type { HelloMessage, HelloMessageConnectionInfo, HelloMessageDebugInfo } from './HelloMessage'; export { Logger, LogLevel } from './logger'; export { SocketModeClient } from './SocketModeClient'; export { SocketModeDispatcher, SocketModeOptions } from './SocketModeOptions'; diff --git a/packages/socket-mode/test/integrations/integration.test.js b/packages/socket-mode/test/integrations/integration.test.js index 6724da519..e52f03ceb 100644 --- a/packages/socket-mode/test/integrations/integration.test.js +++ b/packages/socket-mode/test/integrations/integration.test.js @@ -38,7 +38,7 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => assert.fail(err); }); // Send `Event.ServerHello` - ws.send(JSON.stringify({ type: 'hello' })); + ws.send(JSON.stringify({ type: 'hello', num_connections: 1 })); exposed_ws_connection = ws; }); }); @@ -75,6 +75,15 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => assert.equal(result.url, `ws://localhost:${WSS_PORT}/`); await client.disconnect(); }); + it('emits `hello` with the handshake payload before `start()` resolves', async () => { + let hello = null; + client.on('hello', (message) => { + hello = message; + }); + await client.start(); + assert.deepEqual(hello, { type: 'hello', num_connections: 1 }); + await client.disconnect(); + }); it('can call `disconnect()` even if already disconnected without issue', async () => { await client.disconnect(); });