client.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. const VError = require('verror');
  2. const tls = require('tls');
  3. const extend = require('./util/extend');
  4. const createProxySocket = require('./util/proxy');
  5. module.exports = function (dependencies) {
  6. // Used for routine logs such as HTTP status codes, etc.
  7. const defaultLogger = dependencies.logger;
  8. // Used for unexpected events that should be rare under normal circumstances,
  9. // e.g. connection errors.
  10. const defaultErrorLogger = dependencies.errorLogger || defaultLogger;
  11. const { config, http2 } = dependencies;
  12. const {
  13. HTTP2_HEADER_STATUS,
  14. HTTP2_HEADER_SCHEME,
  15. HTTP2_HEADER_METHOD,
  16. HTTP2_HEADER_AUTHORITY,
  17. HTTP2_HEADER_PATH,
  18. HTTP2_METHOD_POST,
  19. NGHTTP2_CANCEL,
  20. } = http2.constants;
  21. const TIMEOUT_STATUS = '(timeout)';
  22. const ABORTED_STATUS = '(aborted)';
  23. const ERROR_STATUS = '(error)';
  24. function Client(options) {
  25. this.config = config(options);
  26. this.logger = defaultLogger;
  27. this.errorLogger = defaultErrorLogger;
  28. this.healthCheckInterval = setInterval(() => {
  29. if (this.session && !this.session.closed && !this.session.destroyed && !this.isDestroyed) {
  30. this.session.ping((error, duration) => {
  31. if (error) {
  32. this.errorLogger(
  33. 'No Ping response after ' + duration + ' ms with error:' + error.message
  34. );
  35. return;
  36. }
  37. this.logger('Ping response after ' + duration + ' ms');
  38. });
  39. }
  40. }, this.config.heartBeat).unref();
  41. }
  42. // Session should be passed except when destroying the client
  43. Client.prototype.destroySession = function (session, callback) {
  44. if (!session) {
  45. session = this.session;
  46. }
  47. if (session) {
  48. if (this.session === session) {
  49. this.session = null;
  50. }
  51. if (!session.destroyed) {
  52. session.destroy();
  53. }
  54. }
  55. if (callback) {
  56. callback();
  57. }
  58. };
  59. // Session should be passed except when destroying the client
  60. Client.prototype.closeAndDestroySession = function (session, callback) {
  61. if (!session) {
  62. session = this.session;
  63. }
  64. if (session) {
  65. if (this.session === session) {
  66. this.session = null;
  67. }
  68. if (!session.closed) {
  69. session.close(() => this.destroySession(session, callback));
  70. } else {
  71. this.destroySession(session, callback);
  72. }
  73. } else if (callback) {
  74. callback();
  75. }
  76. };
  77. Client.prototype.write = function write(notification, device, count) {
  78. if (this.isDestroyed) {
  79. return Promise.resolve({ device, error: new VError('client is destroyed') });
  80. }
  81. // Connect session
  82. if (!this.session || this.session.closed || this.session.destroyed) {
  83. return this.connect().then(() => this.request(notification, device, count));
  84. }
  85. return this.request(notification, device, count);
  86. };
  87. Client.prototype.connect = function connect() {
  88. if (this.sessionPromise) return this.sessionPromise;
  89. const proxySocketPromise = this.config.proxy
  90. ? createProxySocket(this.config.proxy, {
  91. host: this.config.address,
  92. port: this.config.port,
  93. })
  94. : Promise.resolve();
  95. this.sessionPromise = proxySocketPromise.then(socket => {
  96. this.sessionPromise = null;
  97. if (socket) {
  98. this.config.createConnection = authority =>
  99. authority.protocol === 'http:'
  100. ? socket
  101. : authority.protocol === 'https:'
  102. ? tls.connect(+authority.port || 443, authority.hostname, {
  103. socket,
  104. servername: authority.hostname,
  105. ALPNProtocols: ['h2'],
  106. })
  107. : null;
  108. }
  109. const session = (this.session = http2.connect(
  110. this._mockOverrideUrl || `https://${this.config.address}`,
  111. this.config
  112. ));
  113. this.session.on('close', () => {
  114. if (this.errorLogger.enabled) {
  115. this.errorLogger('Session closed');
  116. }
  117. this.destroySession(session);
  118. });
  119. this.session.on('socketError', error => {
  120. if (this.errorLogger.enabled) {
  121. this.errorLogger(`Socket error: ${error}`);
  122. }
  123. this.closeAndDestroySession(session);
  124. });
  125. this.session.on('error', error => {
  126. if (this.errorLogger.enabled) {
  127. this.errorLogger(`Session error: ${error}`);
  128. }
  129. this.closeAndDestroySession(session);
  130. });
  131. this.session.on('goaway', (errorCode, lastStreamId, opaqueData) => {
  132. if (this.errorLogger.enabled) {
  133. this.errorLogger(
  134. `GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})`
  135. );
  136. }
  137. this.closeAndDestroySession(session);
  138. });
  139. if (this.logger.enabled) {
  140. this.session.on('connect', () => {
  141. this.logger('Session connected');
  142. });
  143. }
  144. this.session.on('frameError', (frameType, errorCode, streamId) => {
  145. // This is a frame error not associate with any request(stream).
  146. if (this.errorLogger.enabled) {
  147. this.errorLogger(
  148. `Frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})`
  149. );
  150. }
  151. this.closeAndDestroySession(session);
  152. });
  153. });
  154. return this.sessionPromise;
  155. };
  156. Client.prototype.request = function request(notification, device, count) {
  157. let tokenGeneration = null;
  158. let status = null;
  159. let responseData = '';
  160. const retryCount = count || 0;
  161. const headers = extend(
  162. {
  163. [HTTP2_HEADER_SCHEME]: 'https',
  164. [HTTP2_HEADER_METHOD]: HTTP2_METHOD_POST,
  165. [HTTP2_HEADER_AUTHORITY]: this.config.address,
  166. [HTTP2_HEADER_PATH]: `/3/device/${device}`,
  167. },
  168. notification.headers
  169. );
  170. if (this.config.token) {
  171. if (this.config.token.isExpired(3300)) {
  172. this.config.token.regenerate(this.config.token.generation);
  173. }
  174. headers.authorization = `bearer ${this.config.token.current}`;
  175. tokenGeneration = this.config.token.generation;
  176. }
  177. const request = this.session.request(headers);
  178. request.setEncoding('utf8');
  179. request.on('response', headers => {
  180. status = headers[HTTP2_HEADER_STATUS];
  181. });
  182. request.on('data', data => {
  183. responseData += data;
  184. });
  185. request.write(notification.body);
  186. return new Promise(resolve => {
  187. request.on('end', () => {
  188. try {
  189. if (this.logger.enabled) {
  190. this.logger(`Request ended with status ${status} and responseData: ${responseData}`);
  191. }
  192. if (status === 200) {
  193. resolve({ device });
  194. } else if ([TIMEOUT_STATUS, ABORTED_STATUS, ERROR_STATUS].includes(status)) {
  195. return;
  196. } else if (responseData !== '') {
  197. const response = JSON.parse(responseData);
  198. if (status === 403 && response.reason === 'ExpiredProviderToken' && retryCount < 2) {
  199. this.config.token.regenerate(tokenGeneration);
  200. resolve(this.write(notification, device, retryCount + 1));
  201. return;
  202. } else if (status === 500 && response.reason === 'InternalServerError') {
  203. this.closeAndDestroySession();
  204. const error = new VError('Error 500, stream ended unexpectedly');
  205. resolve({ device, error });
  206. return;
  207. }
  208. resolve({ device, status, response });
  209. } else {
  210. this.closeAndDestroySession();
  211. const error = new VError(
  212. `stream ended unexpectedly with status ${status} and empty body`
  213. );
  214. resolve({ device, error });
  215. }
  216. } catch (e) {
  217. const error = new VError(e, 'Unexpected error processing APNs response');
  218. if (this.errorLogger.enabled) {
  219. this.errorLogger(`Unexpected error processing APNs response: ${e.message}`);
  220. }
  221. resolve({ device, error });
  222. }
  223. });
  224. request.setTimeout(this.config.requestTimeout, () => {
  225. if (this.errorLogger.enabled) {
  226. this.errorLogger('Request timeout');
  227. }
  228. status = TIMEOUT_STATUS;
  229. request.close(NGHTTP2_CANCEL);
  230. resolve({ device, error: new VError('apn write timeout') });
  231. });
  232. request.on('aborted', () => {
  233. if (this.errorLogger.enabled) {
  234. this.errorLogger('Request aborted');
  235. }
  236. status = ABORTED_STATUS;
  237. resolve({ device, error: new VError('apn write aborted') });
  238. });
  239. request.on('error', error => {
  240. if (this.errorLogger.enabled) {
  241. this.errorLogger(`Request error: ${error}`);
  242. }
  243. status = ERROR_STATUS;
  244. if (typeof error === 'string') {
  245. error = new VError('apn write failed: %s', error);
  246. } else {
  247. error = new VError(error, 'apn write failed');
  248. }
  249. resolve({ device, error });
  250. });
  251. if (this.errorLogger.enabled) {
  252. request.on('frameError', (frameType, errorCode, streamId) => {
  253. this.errorLogger(
  254. `Request frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})`
  255. );
  256. });
  257. }
  258. request.end();
  259. });
  260. };
  261. Client.prototype.shutdown = function shutdown(callback) {
  262. if (this.isDestroyed) {
  263. if (callback) {
  264. callback();
  265. }
  266. return;
  267. }
  268. if (this.errorLogger.enabled) {
  269. this.errorLogger('Called client.shutdown()');
  270. }
  271. this.isDestroyed = true;
  272. if (this.healthCheckInterval) {
  273. clearInterval(this.healthCheckInterval);
  274. this.healthCheckInterval = null;
  275. }
  276. this.closeAndDestroySession(undefined, callback);
  277. };
  278. Client.prototype.setLogger = function (newLogger, newErrorLogger = null) {
  279. if (typeof newLogger !== 'function') {
  280. throw new Error(`Expected newLogger to be a function, got ${typeof newLogger}`);
  281. }
  282. if (newErrorLogger && typeof newErrorLogger !== 'function') {
  283. throw new Error(
  284. `Expected newErrorLogger to be a function or null, got ${typeof newErrorLogger}`
  285. );
  286. }
  287. this.logger = newLogger;
  288. this.errorLogger = newErrorLogger || newLogger;
  289. };
  290. return Client;
  291. };