@superhuman/push-receiver
Version:
A module to subscribe to GCM/FCM and receive notifications within a node process.
224 lines (198 loc) • 6.57 kB
JavaScript
const EventEmitter = require('events');
const Long = require('long');
const Parser = require('./parser');
const decrypt = require('./utils/decrypt');
const path = require('path');
const tls = require('tls');
const { checkIn } = require('./gcm');
const {
kMCSVersion,
kLoginRequestTag,
kDataMessageStanzaTag,
kLoginResponseTag,
} = require('./constants');
const { load } = require('protobufjs');
const HOST = 'mtalk.google.com';
const PORT = 5228;
const MAX_RETRY_TIMEOUT = 15;
let proto = null;
module.exports = class Client extends EventEmitter {
static async init() {
if (proto) {
return;
}
proto = await load(path.resolve(__dirname, 'mcs.proto'));
}
constructor(
credentials,
{ persistentIds, socketTimeout, socketKeepAliveDelay } = {}
) {
super();
this._credentials = credentials;
this._persistentIds = persistentIds || [];
this._socketTimeout = socketTimeout;
this._socketKeepAliveDelay = socketKeepAliveDelay;
this._retryCount = 0;
this._onSocketConnect = this._onSocketConnect.bind(this);
this._onSocketClose = this._onSocketClose.bind(this);
this._onSocketError = this._onSocketError.bind(this);
this._onMessage = this._onMessage.bind(this);
this._onParserError = this._onParserError.bind(this);
this._onSocketTimeout = this._onSocketTimeout.bind(this);
}
async connect() {
await Client.init();
await this._checkIn();
this._connect();
// can happen if the socket immediately closes after being created
if (!this._socket) {
return;
}
await Parser.init();
// can happen if the socket immediately closes after being created
if (!this._socket) {
return;
}
this._parser = new Parser(this._socket);
this._parser.on('message', this._onMessage);
this._parser.on('error', this._onParserError);
}
destroy() {
this._destroy();
}
async _checkIn() {
return checkIn(
this._credentials.gcm.androidId,
this._credentials.gcm.securityToken
);
}
_connect() {
this._socket = new tls.TLSSocket();
if (this._socketTimeout) {
this._socket.setTimeout(this._socketTimeout);
}
// Set default keep alive delay to 10s to prevent connection drop in electron v20+
this._socket.setKeepAlive(true, this._socketKeepAliveDelay || 10 * 1000);
this._socket.on('connect', this._onSocketConnect);
this._socket.on('error', this._onSocketError);
this._socket.on('timeout', this._onSocketTimeout);
this._socket.on('close', this._onSocketClose);
this._socket.connect({ host : HOST, port : PORT });
this._socket.write(this._loginBuffer());
}
_destroy() {
clearTimeout(this._retryTimeout);
if (this._socket) {
this._socket.removeListener('connect', this._onSocketConnect);
this._socket.removeListener('close', this._onSocketClose);
this._socket.removeListener('error', this._onSocketError);
this._socket.destroy();
this._socket = null;
}
if (this._parser) {
this._parser.removeListener('message', this._onMessage);
this._parser.removeListener('error', this._onParserError);
this._parser.destroy();
this._parser = null;
}
}
_loginBuffer() {
const LoginRequestType = proto.lookupType('mcs_proto.LoginRequest');
const hexAndroidId = Long.fromString(
this._credentials.gcm.androidId
).toString(16);
const loginRequest = {
adaptiveHeartbeat : false,
authService : 2,
authToken : this._credentials.gcm.securityToken,
id : 'chrome-63.0.3234.0',
domain : 'mcs.android.com',
deviceId : `android-${hexAndroidId}`,
networkType : 1,
resource : this._credentials.gcm.androidId,
user : this._credentials.gcm.androidId,
useRmq2 : true,
setting : [{ name : 'new_vc', value : '1' }],
// Id of the last notification received
clientEvent : [],
receivedPersistentId : this._persistentIds,
};
const errorMessage = LoginRequestType.verify(loginRequest);
if (errorMessage) {
throw new Error(errorMessage);
}
const buffer = LoginRequestType.encodeDelimited(loginRequest).finish();
return Buffer.concat([
Buffer.from([kMCSVersion, kLoginRequestTag]),
buffer,
]);
}
_onSocketConnect() {
this._retryCount = 0;
this.emit('connect');
}
_onSocketClose() {
this.emit('disconnect');
this._retry();
}
_onSocketError() {
// ignore, the close handler takes care of retry
}
_onSocketTimeout() {
// Socket will be reopened by the _onSocketClose handler
this._socket.destroy();
}
_onParserError() {
this._retry();
}
_retry() {
this._destroy();
const timeout = Math.min(++this._retryCount, MAX_RETRY_TIMEOUT) * 1000;
this._retryTimeout = setTimeout(this.connect.bind(this), timeout);
}
_onMessage({ tag, object }) {
if (tag === kLoginResponseTag) {
// clear persistent ids, as we just sent them to the server while logging
// in
this._persistentIds = [];
} else if (tag === kDataMessageStanzaTag) {
this._onDataMessage(object);
}
}
_onDataMessage(object) {
if (this._persistentIds.includes(object.persistentId)) {
return;
}
let message;
try {
message = decrypt(object, this._credentials.keys);
} catch (error) {
switch (true) {
case error.message.includes(
'Unsupported state or unable to authenticate data'
):
case error.message.includes('crypto-key is missing'):
case error.message.includes('salt is missing'):
// NOTE(ibash) Periodically we're unable to decrypt notifications. In
// all cases we've been able to receive future notifications using the
// same keys. So, we silently drop this notification.
console.warn(
'Message dropped as it could not be decrypted: ' + error.message
);
this._persistentIds.push(object.persistentId);
return;
default: {
throw error;
}
}
}
// Maintain persistentIds updated with the very last received value
this._persistentIds.push(object.persistentId);
// Send notification
this.emit('ON_NOTIFICATION_RECEIVED', {
notification : message,
// Needs to be saved by the client
persistentId : object.persistentId,
});
}
};