2023-01-03 19:55:46 +00:00
|
|
|
// Copyright 2020 Signal Messenger, LLC
|
2020-10-30 20:34:04 +00:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2021-08-26 18:18:00 +00:00
|
|
|
/* eslint-disable max-classes-per-file */
|
2020-04-13 17:37:29 +00:00
|
|
|
/*
|
|
|
|
* WebSocket-Resources
|
|
|
|
*
|
|
|
|
* Create a request-response interface over websockets using the
|
|
|
|
* WebSocket-Resources sub-protocol[1].
|
|
|
|
*
|
|
|
|
* var client = new WebSocketResource(socket, function(request) {
|
|
|
|
* request.respond(200, 'OK');
|
|
|
|
* });
|
|
|
|
*
|
2021-07-28 21:37:09 +00:00
|
|
|
* const { response, status } = await client.sendRequest({
|
2020-04-13 17:37:29 +00:00
|
|
|
* verb: 'PUT',
|
|
|
|
* path: '/v1/messages',
|
2021-07-28 21:37:09 +00:00
|
|
|
* headers: ['content-type:application/json'],
|
|
|
|
* body: Buffer.from('{ some: "json" }'),
|
2020-04-13 17:37:29 +00:00
|
|
|
* });
|
|
|
|
*
|
|
|
|
* 1. https://github.com/signalapp/WebSocket-Resources
|
|
|
|
*
|
|
|
|
*/
|
|
|
|
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { connection as WebSocket, IMessage } from 'websocket';
|
2022-03-23 20:49:27 +00:00
|
|
|
import Long from 'long';
|
2023-06-07 00:36:38 +00:00
|
|
|
import pTimeout from 'p-timeout';
|
2021-06-09 22:28:54 +00:00
|
|
|
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { EventHandler } from './EventTarget';
|
|
|
|
import EventTarget from './EventTarget';
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-08-26 14:10:58 +00:00
|
|
|
import * as durations from '../util/durations';
|
2021-07-02 19:21:24 +00:00
|
|
|
import { dropNull } from '../util/dropNull';
|
2021-06-15 00:12:58 +00:00
|
|
|
import { isOlderThan } from '../util/timestamp';
|
2021-07-02 19:21:24 +00:00
|
|
|
import { strictAssert } from '../util/assert';
|
2021-07-28 21:37:09 +00:00
|
|
|
import * as Errors from '../types/errors';
|
2021-07-02 19:21:24 +00:00
|
|
|
import { SignalService as Proto } from '../protobuf';
|
2021-09-17 18:27:53 +00:00
|
|
|
import * as log from '../logging/log';
|
2021-10-07 18:18:22 +00:00
|
|
|
import * as Timers from '../Timers';
|
2021-06-15 00:12:58 +00:00
|
|
|
|
2021-08-26 14:10:58 +00:00
|
|
|
const THIRTY_SECONDS = 30 * durations.SECOND;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-09-10 18:43:53 +00:00
|
|
|
const MAX_MESSAGE_SIZE = 256 * 1024;
|
2021-07-30 01:08:04 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
export class IncomingWebSocketRequest {
|
2022-03-23 20:49:27 +00:00
|
|
|
private readonly id: Long;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
public readonly verb: string;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
public readonly path: string;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
public readonly body: Uint8Array | undefined;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
public readonly headers: ReadonlyArray<string>;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
constructor(
|
|
|
|
request: Proto.IWebSocketRequestMessage,
|
2021-07-28 21:37:09 +00:00
|
|
|
private readonly sendBytes: (bytes: Buffer) => void
|
2021-07-02 19:21:24 +00:00
|
|
|
) {
|
|
|
|
strictAssert(request.id, 'request without id');
|
|
|
|
strictAssert(request.verb, 'request without verb');
|
|
|
|
strictAssert(request.path, 'request without path');
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
this.id = request.id;
|
|
|
|
this.verb = request.verb;
|
|
|
|
this.path = request.path;
|
|
|
|
this.body = dropNull(request.body);
|
|
|
|
this.headers = request.headers || [];
|
|
|
|
}
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
public respond(status: number, message: string): void {
|
|
|
|
const bytes = Proto.WebSocketMessage.encode({
|
|
|
|
type: Proto.WebSocketMessage.Type.RESPONSE,
|
|
|
|
response: { id: this.id, message, status },
|
|
|
|
}).finish();
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
this.sendBytes(Buffer.from(bytes));
|
2020-04-13 17:37:29 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
export type SendRequestOptions = Readonly<{
|
2020-04-13 17:37:29 +00:00
|
|
|
verb: string;
|
|
|
|
path: string;
|
2021-07-02 19:21:24 +00:00
|
|
|
body?: Uint8Array;
|
2021-07-28 21:37:09 +00:00
|
|
|
timeout?: number;
|
2021-07-02 19:21:24 +00:00
|
|
|
headers?: ReadonlyArray<string>;
|
|
|
|
}>;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
export type SendRequestResult = Readonly<{
|
|
|
|
status: number;
|
|
|
|
message: string;
|
|
|
|
response?: Uint8Array;
|
|
|
|
headers: ReadonlyArray<string>;
|
|
|
|
}>;
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
export type WebSocketResourceOptions = {
|
2023-06-07 00:36:38 +00:00
|
|
|
name: string;
|
2021-07-02 19:21:24 +00:00
|
|
|
handleRequest?: (request: IncomingWebSocketRequest) => void;
|
2023-06-07 00:36:38 +00:00
|
|
|
keepalive?: KeepAliveOptionsType;
|
2021-07-02 19:21:24 +00:00
|
|
|
};
|
|
|
|
|
2021-07-09 19:36:10 +00:00
|
|
|
export class CloseEvent extends Event {
|
|
|
|
constructor(public readonly code: number, public readonly reason: string) {
|
|
|
|
super('close');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-13 17:37:29 +00:00
|
|
|
export default class WebSocketResource extends EventTarget {
|
2022-03-23 20:49:27 +00:00
|
|
|
private outgoingId = Long.fromNumber(1, true);
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
private closed = false;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
private readonly outgoingMap = new Map<
|
2022-03-23 20:49:27 +00:00
|
|
|
string,
|
2021-07-28 21:37:09 +00:00
|
|
|
(result: SendRequestResult) => void
|
|
|
|
>();
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
private readonly boundOnMessage: (message: IMessage) => void;
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2022-03-23 20:49:27 +00:00
|
|
|
private activeRequests = new Set<IncomingWebSocketRequest | string>();
|
2021-07-28 21:37:09 +00:00
|
|
|
|
|
|
|
private shuttingDown = false;
|
|
|
|
|
2021-10-07 18:18:22 +00:00
|
|
|
private shutdownTimer?: Timers.Timeout;
|
2021-07-28 21:37:09 +00:00
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
private readonly logId: string;
|
|
|
|
|
2023-06-14 20:51:49 +00:00
|
|
|
public readonly localPort: number | undefined;
|
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
// Public for tests
|
|
|
|
public readonly keepalive?: KeepAlive;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
private readonly socket: WebSocket,
|
2023-06-07 00:36:38 +00:00
|
|
|
private readonly options: WebSocketResourceOptions
|
2021-07-02 19:21:24 +00:00
|
|
|
) {
|
2020-04-13 17:37:29 +00:00
|
|
|
super();
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
this.logId = `WebSocketResource(${options.name})`;
|
2023-06-14 20:51:49 +00:00
|
|
|
this.localPort = socket.socket.localPort;
|
2023-06-07 00:36:38 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
this.boundOnMessage = this.onMessage.bind(this);
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
socket.on('message', this.boundOnMessage);
|
2021-06-09 22:28:54 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
if (options.keepalive) {
|
|
|
|
const keepalive = new KeepAlive(
|
|
|
|
this,
|
2023-06-07 00:36:38 +00:00
|
|
|
options.name,
|
|
|
|
options.keepalive ?? {}
|
2021-06-09 22:28:54 +00:00
|
|
|
);
|
2021-07-02 19:21:24 +00:00
|
|
|
this.keepalive = keepalive;
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
keepalive.reset();
|
2023-06-07 00:36:38 +00:00
|
|
|
socket.on('close', () => this.keepalive?.stop());
|
2021-07-28 21:37:09 +00:00
|
|
|
socket.on('error', (error: Error) => {
|
2023-06-07 00:36:38 +00:00
|
|
|
log.warn(`${this.logId}: WebSocket error`, Errors.toLogFormat(error));
|
2021-07-28 21:37:09 +00:00
|
|
|
});
|
2020-04-13 17:37:29 +00:00
|
|
|
}
|
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
socket.on('close', (code, reason) => {
|
2020-04-13 17:37:29 +00:00
|
|
|
this.closed = true;
|
2021-07-28 21:37:09 +00:00
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
log.warn(`${this.logId}: Socket closed`);
|
2021-07-28 21:37:09 +00:00
|
|
|
this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
|
2020-04-13 17:37:29 +00:00
|
|
|
});
|
2021-07-28 21:37:09 +00:00
|
|
|
|
|
|
|
this.addEventListener('close', () => this.onClose());
|
2021-07-02 19:21:24 +00:00
|
|
|
}
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-11-12 23:44:20 +00:00
|
|
|
public override addEventListener(
|
2021-07-09 19:36:10 +00:00
|
|
|
name: 'close',
|
|
|
|
handler: (ev: CloseEvent) => void
|
|
|
|
): void;
|
|
|
|
|
2021-11-12 23:44:20 +00:00
|
|
|
public override addEventListener(name: string, handler: EventHandler): void {
|
2021-07-09 19:36:10 +00:00
|
|
|
return super.addEventListener(name, handler);
|
|
|
|
}
|
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
public async sendRequest(
|
|
|
|
options: SendRequestOptions
|
|
|
|
): Promise<SendRequestResult> {
|
2021-07-02 19:21:24 +00:00
|
|
|
const id = this.outgoingId;
|
2022-03-23 20:49:27 +00:00
|
|
|
const idString = id.toString();
|
|
|
|
strictAssert(!this.outgoingMap.has(idString), 'Duplicate outgoing request');
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2022-03-23 20:49:27 +00:00
|
|
|
// Note that this automatically wraps
|
|
|
|
this.outgoingId = this.outgoingId.add(1);
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
const bytes = Proto.WebSocketMessage.encode({
|
|
|
|
type: Proto.WebSocketMessage.Type.REQUEST,
|
|
|
|
request: {
|
|
|
|
verb: options.verb,
|
|
|
|
path: options.path,
|
|
|
|
body: options.body,
|
|
|
|
headers: options.headers ? options.headers.slice() : undefined,
|
|
|
|
id,
|
|
|
|
},
|
|
|
|
}).finish();
|
2021-09-10 20:16:53 +00:00
|
|
|
strictAssert(
|
|
|
|
bytes.length <= MAX_MESSAGE_SIZE,
|
|
|
|
'WebSocket request byte size exceeded'
|
|
|
|
);
|
2021-07-28 21:37:09 +00:00
|
|
|
|
|
|
|
strictAssert(!this.shuttingDown, 'Cannot send request, shutting down');
|
2022-03-23 20:49:27 +00:00
|
|
|
this.addActive(idString);
|
2021-07-28 21:37:09 +00:00
|
|
|
const promise = new Promise<SendRequestResult>((resolve, reject) => {
|
2023-05-17 16:43:33 +00:00
|
|
|
const sentAt = Date.now();
|
|
|
|
let timedOut = false;
|
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
let timer = options.timeout
|
2021-10-07 18:18:22 +00:00
|
|
|
? Timers.setTimeout(() => {
|
2023-05-17 16:43:33 +00:00
|
|
|
timedOut = true;
|
2022-03-23 20:49:27 +00:00
|
|
|
this.removeActive(idString);
|
2023-05-17 16:43:33 +00:00
|
|
|
reject(new Error(`Request timed out; id: [${idString}]`));
|
2021-07-28 21:37:09 +00:00
|
|
|
}, options.timeout)
|
|
|
|
: undefined;
|
|
|
|
|
2022-03-23 20:49:27 +00:00
|
|
|
this.outgoingMap.set(idString, result => {
|
2021-07-28 21:37:09 +00:00
|
|
|
if (timer !== undefined) {
|
2021-10-07 18:18:22 +00:00
|
|
|
Timers.clearTimeout(timer);
|
2021-07-28 21:37:09 +00:00
|
|
|
timer = undefined;
|
|
|
|
}
|
2023-05-17 16:43:33 +00:00
|
|
|
if (timedOut) {
|
|
|
|
log.warn(
|
2023-06-07 00:36:38 +00:00
|
|
|
`${this.logId}: Response received after timeout; ` +
|
|
|
|
`id: [${idString}], path: [${options.path}], ` +
|
|
|
|
`response time: ${Date.now() - sentAt}ms`
|
2023-05-17 16:43:33 +00:00
|
|
|
);
|
2023-06-07 00:36:38 +00:00
|
|
|
} else {
|
|
|
|
// Reset keepalive when an on-time response arrives
|
|
|
|
this.keepalive?.reset();
|
2023-05-17 16:43:33 +00:00
|
|
|
}
|
2022-03-23 20:49:27 +00:00
|
|
|
this.removeActive(idString);
|
2021-07-28 21:37:09 +00:00
|
|
|
resolve(result);
|
|
|
|
});
|
|
|
|
});
|
2021-07-02 19:21:24 +00:00
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
this.socket.sendBytes(Buffer.from(bytes));
|
|
|
|
|
|
|
|
return promise;
|
2020-04-13 17:37:29 +00:00
|
|
|
}
|
2021-06-09 22:28:54 +00:00
|
|
|
|
|
|
|
public forceKeepAlive(): void {
|
|
|
|
if (!this.keepalive) {
|
|
|
|
return;
|
|
|
|
}
|
2022-12-21 18:41:48 +00:00
|
|
|
void this.keepalive.send();
|
2021-06-09 22:28:54 +00:00
|
|
|
}
|
2021-07-02 19:21:24 +00:00
|
|
|
|
|
|
|
public close(code = 3000, reason?: string): void {
|
|
|
|
if (this.closed) {
|
2023-06-07 00:36:38 +00:00
|
|
|
log.info(`${this.logId}.close: Already closed! ${code}/${reason}`);
|
2021-07-02 19:21:24 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
log.info(`${this.logId}.close(${code})`);
|
2021-07-02 19:21:24 +00:00
|
|
|
if (this.keepalive) {
|
|
|
|
this.keepalive.stop();
|
|
|
|
}
|
|
|
|
|
|
|
|
this.socket.close(code, reason);
|
|
|
|
|
|
|
|
this.socket.removeListener('message', this.boundOnMessage);
|
|
|
|
|
|
|
|
// On linux the socket can wait a long time to emit its close event if we've
|
|
|
|
// lost the internet connection. On the order of minutes. This speeds that
|
|
|
|
// process up.
|
2021-10-07 18:18:22 +00:00
|
|
|
Timers.setTimeout(() => {
|
2021-07-02 19:21:24 +00:00
|
|
|
if (this.closed) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
log.warn(`${this.logId}.close: Dispatching our own socket close event`);
|
2021-07-09 19:36:10 +00:00
|
|
|
this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
|
2023-06-07 00:36:38 +00:00
|
|
|
}, 5 * durations.SECOND);
|
2021-07-02 19:21:24 +00:00
|
|
|
}
|
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
public shutdown(): void {
|
|
|
|
if (this.closed) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.activeRequests.size === 0) {
|
2023-06-07 00:36:38 +00:00
|
|
|
log.info(`${this.logId}.shutdown: no active requests, closing`);
|
2021-07-28 21:37:09 +00:00
|
|
|
this.close(3000, 'Shutdown');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.shuttingDown = true;
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
log.info(`${this.logId}.shutdown: shutting down`);
|
2021-10-07 18:18:22 +00:00
|
|
|
this.shutdownTimer = Timers.setTimeout(() => {
|
2021-07-28 21:37:09 +00:00
|
|
|
if (this.closed) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
log.warn(`${this.logId}.shutdown: Failed to shutdown gracefully`);
|
2021-07-28 21:37:09 +00:00
|
|
|
this.close(3000, 'Shutdown');
|
|
|
|
}, THIRTY_SECONDS);
|
|
|
|
}
|
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
private onMessage({ type, binaryData }: IMessage): void {
|
|
|
|
if (type !== 'binary' || !binaryData) {
|
|
|
|
throw new Error(`Unsupported websocket message type: ${type}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const message = Proto.WebSocketMessage.decode(binaryData);
|
|
|
|
if (
|
|
|
|
message.type === Proto.WebSocketMessage.Type.REQUEST &&
|
|
|
|
message.request
|
|
|
|
) {
|
|
|
|
const handleRequest =
|
|
|
|
this.options.handleRequest ||
|
|
|
|
(request => request.respond(404, 'Not found'));
|
2021-07-28 21:37:09 +00:00
|
|
|
|
|
|
|
const incomingRequest = new IncomingWebSocketRequest(
|
|
|
|
message.request,
|
|
|
|
(bytes: Buffer): void => {
|
|
|
|
this.removeActive(incomingRequest);
|
|
|
|
|
2021-07-30 01:08:04 +00:00
|
|
|
strictAssert(
|
|
|
|
bytes.length <= MAX_MESSAGE_SIZE,
|
|
|
|
'WebSocket response byte size exceeded'
|
|
|
|
);
|
2021-07-28 21:37:09 +00:00
|
|
|
this.socket.sendBytes(bytes);
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
if (this.shuttingDown) {
|
2021-09-15 18:44:27 +00:00
|
|
|
incomingRequest.respond(-1, 'Shutting down');
|
2021-07-28 21:37:09 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.addActive(incomingRequest);
|
|
|
|
handleRequest(incomingRequest);
|
2021-07-02 19:21:24 +00:00
|
|
|
} else if (
|
|
|
|
message.type === Proto.WebSocketMessage.Type.RESPONSE &&
|
|
|
|
message.response
|
|
|
|
) {
|
|
|
|
const { response } = message;
|
|
|
|
strictAssert(response.id, 'response without id');
|
|
|
|
|
2022-03-23 20:49:27 +00:00
|
|
|
const responseIdString = response.id.toString();
|
|
|
|
const resolve = this.outgoingMap.get(responseIdString);
|
|
|
|
this.outgoingMap.delete(responseIdString);
|
2021-07-02 19:21:24 +00:00
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
if (!resolve) {
|
2022-03-23 20:49:27 +00:00
|
|
|
throw new Error(`Received response for unknown request ${response.id}`);
|
2021-07-02 19:21:24 +00:00
|
|
|
}
|
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
resolve({
|
|
|
|
status: response.status ?? -1,
|
|
|
|
message: response.message ?? '',
|
|
|
|
response: dropNull(response.body),
|
|
|
|
headers: response.headers ?? [],
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2021-07-02 19:21:24 +00:00
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
private onClose(): void {
|
|
|
|
const outgoing = new Map(this.outgoingMap);
|
|
|
|
this.outgoingMap.clear();
|
|
|
|
|
|
|
|
for (const resolve of outgoing.values()) {
|
|
|
|
resolve({
|
2021-09-15 18:44:27 +00:00
|
|
|
status: -1,
|
2021-07-28 21:37:09 +00:00
|
|
|
message: 'Connection closed',
|
|
|
|
response: undefined,
|
|
|
|
headers: [],
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2021-07-02 19:21:24 +00:00
|
|
|
|
2022-03-23 20:49:27 +00:00
|
|
|
private addActive(request: IncomingWebSocketRequest | string): void {
|
2021-07-28 21:37:09 +00:00
|
|
|
this.activeRequests.add(request);
|
|
|
|
}
|
2021-07-02 19:21:24 +00:00
|
|
|
|
2022-03-23 20:49:27 +00:00
|
|
|
private removeActive(request: IncomingWebSocketRequest | string): void {
|
2021-07-28 21:37:09 +00:00
|
|
|
if (!this.activeRequests.has(request)) {
|
2023-06-07 00:36:38 +00:00
|
|
|
log.warn(`${this.logId}.removeActive: removing unknown request`);
|
2021-07-28 21:37:09 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.activeRequests.delete(request);
|
|
|
|
if (this.activeRequests.size !== 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (!this.shuttingDown) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.shutdownTimer) {
|
2021-10-07 18:18:22 +00:00
|
|
|
Timers.clearTimeout(this.shutdownTimer);
|
2021-07-28 21:37:09 +00:00
|
|
|
this.shutdownTimer = undefined;
|
2021-07-02 19:21:24 +00:00
|
|
|
}
|
2021-07-28 21:37:09 +00:00
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
log.info(`${this.logId}.removeActive: shutdown complete`);
|
2021-07-28 21:37:09 +00:00
|
|
|
this.close(3000, 'Shutdown');
|
2021-07-02 19:21:24 +00:00
|
|
|
}
|
2020-04-13 17:37:29 +00:00
|
|
|
}
|
|
|
|
|
2021-07-02 19:21:24 +00:00
|
|
|
export type KeepAliveOptionsType = {
|
2020-04-13 17:37:29 +00:00
|
|
|
path?: string;
|
|
|
|
};
|
|
|
|
|
2022-08-16 23:49:47 +00:00
|
|
|
// 30 seconds + 5 seconds for closing the socket above.
|
|
|
|
const KEEPALIVE_INTERVAL_MS = 30 * durations.SECOND;
|
2023-06-07 00:36:38 +00:00
|
|
|
|
|
|
|
// If the machine was in suspended mode for more than 5 minutes - trigger
|
|
|
|
// immediate disconnect.
|
|
|
|
const STALE_THRESHOLD_MS = 5 * durations.MINUTE;
|
|
|
|
|
|
|
|
// If we don't receive a response to keepalive request within 10 seconds -
|
|
|
|
// close the socket.
|
|
|
|
const KEEPALIVE_TIMEOUT_MS = 10 * durations.SECOND;
|
|
|
|
|
2023-05-17 16:43:33 +00:00
|
|
|
const LOG_KEEPALIVE_AFTER_MS = 500;
|
2021-06-15 00:12:58 +00:00
|
|
|
|
2020-04-13 17:37:29 +00:00
|
|
|
class KeepAlive {
|
2021-10-07 18:18:22 +00:00
|
|
|
private keepAliveTimer: Timers.Timeout | undefined;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-06-09 22:28:54 +00:00
|
|
|
private path: string;
|
2020-09-24 21:53:21 +00:00
|
|
|
|
2021-06-09 22:28:54 +00:00
|
|
|
private wsr: WebSocketResource;
|
2020-04-13 17:37:29 +00:00
|
|
|
|
2021-06-15 00:12:58 +00:00
|
|
|
private lastAliveAt: number = Date.now();
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
private logId: string;
|
|
|
|
|
2020-04-13 17:37:29 +00:00
|
|
|
constructor(
|
|
|
|
websocketResource: WebSocketResource,
|
2023-06-07 00:36:38 +00:00
|
|
|
name: string,
|
2020-04-13 17:37:29 +00:00
|
|
|
opts: KeepAliveOptionsType = {}
|
|
|
|
) {
|
2023-06-07 00:36:38 +00:00
|
|
|
this.logId = `WebSocketResources.KeepAlive(${name})`;
|
2020-04-13 17:37:29 +00:00
|
|
|
if (websocketResource instanceof WebSocketResource) {
|
2023-06-07 00:36:38 +00:00
|
|
|
this.path = opts.path ?? '/';
|
2020-04-13 17:37:29 +00:00
|
|
|
this.wsr = websocketResource;
|
|
|
|
} else {
|
|
|
|
throw new TypeError('KeepAlive expected a WebSocketResource');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-09 22:28:54 +00:00
|
|
|
public stop(): void {
|
|
|
|
this.clearTimers();
|
2020-04-13 17:37:29 +00:00
|
|
|
}
|
|
|
|
|
2021-07-28 21:37:09 +00:00
|
|
|
public async send(): Promise<void> {
|
2021-06-09 22:28:54 +00:00
|
|
|
this.clearTimers();
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
const isStale = isOlderThan(this.lastAliveAt, STALE_THRESHOLD_MS);
|
|
|
|
if (isStale) {
|
|
|
|
log.info(`${this.logId}.send: disconnecting due to stale state`);
|
2021-06-15 00:12:58 +00:00
|
|
|
this.wsr.close(
|
|
|
|
3001,
|
|
|
|
`Last keepalive request was too far in the past: ${this.lastAliveAt}`
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
log.info(`${this.logId}.send: Sending a keepalive message`);
|
2023-05-17 16:43:33 +00:00
|
|
|
const sentAt = Date.now();
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
try {
|
|
|
|
const { status } = await pTimeout(
|
|
|
|
this.wsr.sendRequest({
|
|
|
|
verb: 'GET',
|
|
|
|
path: this.path,
|
|
|
|
}),
|
|
|
|
KEEPALIVE_TIMEOUT_MS
|
|
|
|
);
|
|
|
|
|
|
|
|
if (status < 200 || status >= 300) {
|
|
|
|
log.warn(`${this.logId}.send: keepalive response status ${status}`);
|
|
|
|
this.wsr.close(3001, `keepalive response with ${status} code`);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
this.wsr.close(3001, 'No response to keepalive request');
|
|
|
|
return;
|
|
|
|
}
|
2021-07-28 21:37:09 +00:00
|
|
|
|
2023-05-17 16:43:33 +00:00
|
|
|
const responseTime = Date.now() - sentAt;
|
|
|
|
if (responseTime > LOG_KEEPALIVE_AFTER_MS) {
|
|
|
|
log.warn(
|
2023-06-07 00:36:38 +00:00
|
|
|
`${this.logId}.send: delayed response to keepalive request, ` +
|
|
|
|
`response time: ${responseTime}ms`
|
2023-05-17 16:43:33 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-06-07 00:36:38 +00:00
|
|
|
// Successful response on time
|
|
|
|
this.reset();
|
2021-06-09 22:28:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public reset(): void {
|
2021-06-15 00:12:58 +00:00
|
|
|
this.lastAliveAt = Date.now();
|
|
|
|
|
2021-06-09 22:28:54 +00:00
|
|
|
this.clearTimers();
|
|
|
|
|
2021-10-07 18:18:22 +00:00
|
|
|
this.keepAliveTimer = Timers.setTimeout(
|
|
|
|
() => this.send(),
|
|
|
|
KEEPALIVE_INTERVAL_MS
|
|
|
|
);
|
2021-06-09 22:28:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
private clearTimers(): void {
|
|
|
|
if (this.keepAliveTimer) {
|
2021-10-07 18:18:22 +00:00
|
|
|
Timers.clearTimeout(this.keepAliveTimer);
|
2021-06-09 22:28:54 +00:00
|
|
|
this.keepAliveTimer = undefined;
|
|
|
|
}
|
2020-04-13 17:37:29 +00:00
|
|
|
}
|
|
|
|
}
|