From 0ab1aac886d79848165d747db45a2a9e7a992c61 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sun, 8 Nov 2020 11:12:04 -0500 Subject: [PATCH] Handle HTTP2 Session Errors (#40) * Refactor http2-client to properly handle session errors * Rename arg --- CHANGELOG.md | 6 +++ index.d.ts | 65 ++++++++++++++------------- lib/apns.js | 20 +++++++-- lib/http2-client.js | 107 +++++++++++++++++++++++++++++++++++--------- package.json | 2 +- 5 files changed, 142 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f22889..e6400af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ --- +## [9.1.0](https://github.com/AndrewBarba/apns2/releases/tag/9.1.0) + +1. Correctly handle socket error events +2. Lazily connect on first request +3. Keeps socket alive with ping request every 60s + ## [9.0.0](https://github.com/AndrewBarba/apns2/releases/tag/9.0.0) 1. Full code cleanup diff --git a/index.d.ts b/index.d.ts index 30825a3..8341d48 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,4 @@ -import { EventEmitter } from "events" +import { EventEmitter } from 'events' export class APNS extends EventEmitter { constructor(options: APNSOptions) @@ -29,10 +29,10 @@ export class SilentNotification extends Notification { type ResponseError = { error: { - reason: string; - statusCode: number; - notification: Notification; - }; + reason: string + statusCode: number + notification: Notification + } } export const Errors: { @@ -77,35 +77,38 @@ declare interface APNSOptions { defaultTopic?: string host?: string port?: number - connections?: number + requestTimeout?: number + pingInterval?: number } declare interface NotificationOptions { - alert?: string | { - title?: string; - subtitle?: string; - body: string; - 'title-loc-key'?: string; - 'title-loc-args'?: string[]; - 'subtitle-loc-key'?: string; - 'subtitle-loc-args'?: string[]; - 'loc-key'?: string; - 'loc-args'?: string[]; - 'action-loc-key'?: string; - 'launch-image'?: string; - }; - aps?: any; - badge?: number; - category?: string; - collapseId?: string; - contentAvailable?: boolean; - data?: { [key: string]: any; }; - expiration?: number; - priority?: number; - pushType?: keyof PushType; - sound?: string; - threadId?: string; - topic?: string; + alert?: + | string + | { + title?: string + subtitle?: string + body: string + 'title-loc-key'?: string + 'title-loc-args'?: string[] + 'subtitle-loc-key'?: string + 'subtitle-loc-args'?: string[] + 'loc-key'?: string + 'loc-args'?: string[] + 'action-loc-key'?: string + 'launch-image'?: string + } + aps?: any + badge?: number + category?: string + collapseId?: string + contentAvailable?: boolean + data?: { [key: string]: any } + expiration?: number + priority?: number + pushType?: keyof PushType + sound?: string + threadId?: string + topic?: string } declare interface NotificationPriority { diff --git a/lib/apns.js b/lib/apns.js index c0f37f7..8b24738 100644 --- a/lib/apns.js +++ b/lib/apns.js @@ -34,7 +34,7 @@ const SIGNING_ALGORITHM = `ES256` * @const * @desc Reset our signing token every 55 minutes as reccomended by Apple */ -const RESET_TOKEN_INTERVAL = 55 * 60 * 1000 +const RESET_TOKEN_INTERVAL_MS = 55 * 60 * 1000 /** * @class APNS @@ -50,7 +50,16 @@ class APNS extends EventEmitter { * @param {Int} [options.port] * @param {Int} [options.connections] */ - constructor({ team, keyId, signingKey, defaultTopic = null, host = HOST, port = PORT }) { + constructor({ + team, + keyId, + signingKey, + defaultTopic = null, + host = HOST, + port = PORT, + requestTimeout = 5000, + pingInterval = 60000 + }) { if (!team) throw new Error(`team is required`) if (!keyId) throw new Error(`keyId is required`) if (!signingKey) throw new Error(`signingKey is required`) @@ -59,8 +68,11 @@ class APNS extends EventEmitter { this._keyId = keyId this._signingKey = signingKey this._defaultTopic = defaultTopic - this._client = new Http2Client(host, port) - this._interval = setInterval(() => this._resetSigningToken(), RESET_TOKEN_INTERVAL).unref() + this._client = new Http2Client(host, { port, requestTimeout, pingInterval }) + this._resetTokenInterval = setInterval( + () => this._resetSigningToken(), + RESET_TOKEN_INTERVAL_MS + ).unref() this.on(Errors.expiredProviderToken, () => this._resetSigningToken()) } diff --git a/lib/http2-client.js b/lib/http2-client.js index 386b837..e8cfb93 100644 --- a/lib/http2-client.js +++ b/lib/http2-client.js @@ -1,5 +1,6 @@ const http2 = require('http2') const { + HTTP2_HEADER_SCHEME, HTTP2_HEADER_METHOD, HTTP2_HEADER_PATH, HTTP2_HEADER_STATUS, @@ -13,10 +14,12 @@ class HTTP2Client { /** * @constructor */ - constructor(host, port = 443, { timeout = 5000 } = {}) { + constructor(host, { port = 443, requestTimeout = 5000, pingInterval = 60000 } = {}) { if (!host) throw new Error('host is required') - this._timeout = timeout - this._client = http2.connect(`https://${host}:${port}`) + this._requestTimeout = requestTimeout + this._pingIntervalMs = pingInterval + this._pingInterval = null + this._url = `https://${host}:${port}` } /** @@ -27,20 +30,34 @@ class HTTP2Client { } /** - * @param {Number} + * Closes the underlying http2 client + * + * @method close */ - get timeout() { - return this._timeout + close(client = this._client) { + return new Promise((resolve) => { + if (client && !client.closed) { + client.close(() => resolve()) + } else { + resolve() + } + }) } /** - * @method close + * Destroys the underlying http2 client + * + * @method destroy */ - close() { - this.client.close() + destroy(client = this._client) { + if (client && !client.destroyed) { + client.destroy(...arguments) + } } /** + * Sends an http2 request + * * @method request * @param {Object} options * @param {String} options.method @@ -54,27 +71,40 @@ class HTTP2Client { if (!path) throw new Error('path is required') return new Promise((resolve, reject) => { - headers[HTTP2_HEADER_METHOD] = method - headers[HTTP2_HEADER_PATH] = path + Object.assign(headers, { + [HTTP2_HEADER_SCHEME]: 'https', + [HTTP2_HEADER_METHOD]: method, + [HTTP2_HEADER_PATH]: path + }) - const req = this.client.request(headers) + const req = this._getOrCreateClient().request(headers) + + // Store response properties + let responseHeaders = {} + let responseBody = '' // Cancel request after timeout - req.setTimeout(this.timeout, () => { + req.setTimeout(this._requestTimeout, () => { req.close(NGHTTP2_CANCEL) reject(new Error(`http2: timeout ${method} ${path}`)) }) - // Response handling + // Response header handling req.on('response', (headers) => { - let data = '' - req.on('data', (chunk) => (data += chunk)) - req.on('end', () => { - resolve({ - statusCode: headers[HTTP2_HEADER_STATUS], - headers: headers, - body: data - }) + responseHeaders = headers + }) + + // Response body handling + req.on('data', (chunk) => { + responseBody += chunk + }) + + // End request handling + req.on('end', () => { + resolve({ + statusCode: responseHeaders[HTTP2_HEADER_STATUS], + headers: responseHeaders, + body: responseBody }) }) @@ -89,6 +119,39 @@ class HTTP2Client { req.end() }) } + + /** + * Returns an existing client or creates a new one + * + * @private + * @method _getOrCreateClient + */ + _getOrCreateClient() { + if (this._client) { + return this._client + } + const client = http2.connect(this._url) + client.on('close', () => this._closeAndDestroy(client)) + client.on('error', () => this._closeAndDestroy(client)) + client.on('socketError', () => this._closeAndDestroy(client)) + client.on('goaway', () => this._closeAndDestroy(client)) + this._client = client + this._pingInterval = setInterval(() => client.ping(), this._pingIntervalMs).unref() + return client + } + + /** + * Closes and destorys the existing client. A new client will be created on next request + * + * @private + * @method _closeAndDestroy + */ + async _closeAndDestroy(client) { + this._client = null + clearInterval(this._pingInterval) + await this.close(client) + this.destroy(client) + } } module.exports = HTTP2Client diff --git a/package.json b/package.json index 190b82e..502003e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apns2", - "version": "9.0.0", + "version": "9.1.0", "description": "Node client for connecting to Apple's Push Notification Service using the new HTTP/2 protocol with JSON web tokens.", "author": "Andrew Barba ", "main": "lib/apns.js",