Skip to content

Commit

Permalink
Handle HTTP2 Session Errors (#40)
Browse files Browse the repository at this point in the history
* Refactor http2-client to properly handle session errors

* Rename arg
  • Loading branch information
AndrewBarba authored Nov 8, 2020
1 parent d6cda80 commit 0ab1aac
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 58 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 34 additions & 31 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EventEmitter } from "events"
import { EventEmitter } from 'events'

export class APNS extends EventEmitter {
constructor(options: APNSOptions)
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 16 additions & 4 deletions lib/apns.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`)
Expand All @@ -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())
}

Expand Down
107 changes: 85 additions & 22 deletions lib/http2-client.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const http2 = require('http2')
const {
HTTP2_HEADER_SCHEME,
HTTP2_HEADER_METHOD,
HTTP2_HEADER_PATH,
HTTP2_HEADER_STATUS,
Expand All @@ -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}`
}

/**
Expand All @@ -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
Expand All @@ -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
})
})

Expand All @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
"main": "lib/apns.js",
Expand Down

0 comments on commit 0ab1aac

Please sign in to comment.