Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/socket-mode-hello-event.md
Original file line number Diff line number Diff line change
@@ -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`
13 changes: 13 additions & 0 deletions packages/socket-mode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions packages/socket-mode/src/HelloMessage.ts
Original file line number Diff line number Diff line change
@@ -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;
}
49 changes: 49 additions & 0 deletions packages/socket-mode/src/SocketModeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 4 additions & 0 deletions packages/socket-mode/src/SocketModeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/socket-mode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 10 additions & 1 deletion packages/socket-mode/test/integrations/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
Expand Down Expand Up @@ -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();
});
Expand Down