@parse/node-apn
Advanced tools
Comparing version 6.3.0 to 6.4.0
@@ -0,1 +1,8 @@ | ||
# [6.4.0](https://github.com/parse-community/node-apn/compare/6.3.0...6.4.0) (2025-02-06) | ||
### Features | ||
* Add the ability to manage channels and send broadcasts ([#164](https://github.com/parse-community/node-apn/issues/164)) ([ab234c9](https://github.com/parse-community/node-apn/commit/ab234c92a53f71b5738e7ae6ca661ac6f75d556d)) | ||
# [6.3.0](https://github.com/parse-community/node-apn/compare/6.2.1...6.3.0) (2024-12-28) | ||
@@ -2,0 +9,0 @@ |
160
index.d.ts
@@ -34,7 +34,7 @@ /// <reference types="node" /> | ||
/** | ||
* An array of trusted certificates. Each element should contain either a filename to load, or a Buffer/String (in PEM format) to be used directly. If this is omitted several well known "root" CAs will be used. - You may need to use this as some environments don't include the CA used by Apple (entrust_2048). | ||
* An array of trusted certificates. Each element should contain either a filename to load, or a Buffer/String (in PEM format) to be used directly. If this is omitted several well known "root" CAs will be used. - You may need to use this as some environments don't include the CA used by Apple (entrust_2048) | ||
*/ | ||
ca?: (string|Buffer)[]; | ||
/** | ||
* File path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer containing the PFX data. If supplied will always be used instead of certificate and key above. | ||
* File path for private key, certificate and CA certs in PFX or PKCS12 format, or a Buffer containing the PFX data. If supplied will always be used instead of certificate and key above | ||
*/ | ||
@@ -51,2 +51,26 @@ pfx?: string|Buffer; | ||
/** | ||
* The address of the APNs server to send notifications to. If not provided, will connect to standard APNs server | ||
*/ | ||
address?: string; | ||
/** | ||
* The port of the APNs server to send notifications to. (Defaults to 443) | ||
*/ | ||
port?: number; | ||
/** | ||
* The address of the APNs channel management server to send notifications to. If not provided, will connect to standard APNs channel management server | ||
*/ | ||
manageChannelsAddress?: string; | ||
/** | ||
* The port of the APNs channel management server to send notifications to. If not provided, will connect to standard APNs channel management port | ||
*/ | ||
manageChannelsPort?: number; | ||
/** | ||
* Connect through an HTTP proxy when sending notifications | ||
*/ | ||
proxy?: { host: string, port: number|string } | ||
/** | ||
* Connect through an HTTP proxy when managing channels | ||
*/ | ||
manageChannelsProxy?: { host: string, port: number|string } | ||
/** | ||
* Reject Unauthorized property to be passed through to tls.connect() (Defaults to `true`) | ||
@@ -60,9 +84,9 @@ */ | ||
/** | ||
* The delay interval in ms that apn will ping APNs servers. (Defaults to: 60000) | ||
*/ | ||
heartBeat?: number; | ||
/** | ||
* The maximum time in ms that apn will wait for a response to a request. (Defaults to: 5000) | ||
*/ | ||
requestTimeout?: number; | ||
/** | ||
* Connect through an HTTP proxy | ||
*/ | ||
proxy?: { host: string, port: number|string } | ||
} | ||
@@ -105,2 +129,14 @@ | ||
category?: string | ||
"thread-id"?: string | ||
"target-content-id"?: string | ||
"interruption-level"?: string | ApsNotificationInterruptionLevel | ||
"relevance-score"?: number | ||
"filter-criteria"?: string | ||
"stale-date"?: number | ||
"content-state"?: Object | ||
timestamp?: number | ||
event?: string | ||
"dismissal-date"?: number | ||
"attributes-type"?: string | ||
attributes?: Object | ||
} | ||
@@ -111,2 +147,14 @@ | ||
} | ||
export interface BroadcastResponse { | ||
bundleId: string; | ||
"apns-request-id"?: string; | ||
"apns-channel-id"?: string; | ||
"message-storage-policy"?: number; | ||
"push-type"?: string; | ||
"channels"?: string[]; | ||
} | ||
export interface LoggerResponse extends Partial<ResponseSent>, Partial<BroadcastResponse> {} | ||
export interface ResponseFailure { | ||
@@ -122,19 +170,47 @@ device: string; | ||
export interface Responses { | ||
sent: ResponseSent[]; | ||
failed: ResponseFailure[]; | ||
export interface BroadcastResponseFailure extends Omit<ResponseFailure, "device"> { | ||
bundleId: string; | ||
} | ||
export interface LoggerResponseFailure extends Partial<ResponseFailure>, Partial<BroadcastResponseFailure> {} | ||
export interface Responses<R,F> { | ||
sent: R[]; | ||
failed: F[]; | ||
} | ||
export class Provider extends EventEmitter { | ||
constructor(options: ProviderOptions); | ||
/** | ||
* This is main interface for sending notifications. Create a Notification object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. | ||
* This is main interface for sending notifications. | ||
* | ||
* @remarks | ||
* Create a Notification object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. | ||
* | ||
* A "recipient" is a String containing the hex-encoded device token. | ||
* @param notification - The notification to send. | ||
* @param recipients - A String or an Array of Strings containing the hex-encoded device token. | ||
*/ | ||
send(notification: Notification, recipients: string|string[]): Promise<Responses>; | ||
send(notification: Notification, recipients: string|string[]): Promise<Responses<ResponseSent,ResponseFailure>>; | ||
/** | ||
* Manage channels using a specific action. | ||
* | ||
* @param notifications - A Notification or an Array of Notifications to send. Each notification should specify the respective channelId it's directed to. | ||
* @param bundleId - The bundleId for your application. | ||
* @param action - Specifies the action to perform on the channel(s). | ||
*/ | ||
manageChannels(notifications: Notification|Notification[], bundleId: string, action: ChannelAction): Promise<Responses<BroadcastResponse,BroadcastResponseFailure>>; | ||
/** | ||
* Broadcast notificaitons to channel(s). | ||
* | ||
* @param notifications - A Notification or an Array of Notifications to send. Each notification should specify the respective channelId it's directed to. | ||
* @param bundleId: The bundleId for your application. | ||
*/ | ||
broadcast(notifications: Notification|Notification[], bundleId: string): Promise<Responses<BroadcastResponse,BroadcastResponseFailure>>; | ||
/** | ||
* Set an info logger, and optionally an errorLogger to separately log errors. | ||
* | ||
* @remarks | ||
* In order to log, these functions must have a property '.enabled' that is true. | ||
@@ -144,3 +220,3 @@ * (The default logger uses the npm 'debug' module which sets '.enabled' | ||
*/ | ||
setLogger(logger: (msg: string) => void, errorLogger?: (msg: string) => void): Promise<Responses>; | ||
setLogger(logger: (msg: string) => void, errorLogger?: (msg: string) => void): Promise<Responses<LoggerResponse,LoggerResponseFailure>>; | ||
@@ -150,3 +226,3 @@ /** | ||
*/ | ||
shutdown(callback?: () => void): void; | ||
shutdown(callback?: () => void): Promise<void>; | ||
} | ||
@@ -157,16 +233,38 @@ | ||
/** | ||
* This is main interface for sending notifications. Create a Notification object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. | ||
* This is main interface for sending notifications. | ||
* | ||
* @remarks | ||
* Create a Notification object and pass it in, along with a single recipient or an array of them and node-apn will take care of the rest, delivering a copy of the notification to each recipient. | ||
* | ||
* A "recipient" is a String containing the hex-encoded device token. | ||
* @param notification - The notification to send. | ||
* @param recipients - A String or an Array of Strings containing the hex-encoded device token. | ||
*/ | ||
send(notification: Notification, recipients: string|string[]): Promise<Responses>; | ||
send(notification: Notification, recipients: string|string[]): Promise<Responses<ResponseSent,ResponseFailure>>; | ||
/** | ||
* Manage channels using a specific action. | ||
* | ||
* @param notifications - A Notification or an Array of Notifications to send. Each notification should specify the respective channelId it's directed to. | ||
* @param bundleId - The bundleId for your application. | ||
* @param action - Specifies the action to perform on the channel(s). | ||
*/ | ||
manageChannels(notifications: Notification|Notification[], bundleId: string, action: ChannelAction): Promise<Responses<BroadcastResponse,BroadcastResponseFailure>>; | ||
/** | ||
* Broadcast notificaitons to channel(s). | ||
* | ||
* @param notifications - A Notification or an Array of Notifications to send. Each notification should specify the respective channelId it's directed to. | ||
* @param bundleId: The bundleId for your application. | ||
*/ | ||
broadcast(notifications: Notification|Notification[], bundleId: string): Promise<Responses<BroadcastResponse,BroadcastResponseFailure>>; | ||
/** | ||
* Set an info logger, and optionally an errorLogger to separately log errors. | ||
* | ||
* @remarks | ||
* In order to log, these functions must have a property '.enabled' that is true. | ||
* (The default logger uses the npm 'debug' module which sets '.enabled' | ||
* (The default logger uses the npm 'debug' module which sets '.enabled' | ||
* based on the DEBUG environment variable) | ||
*/ | ||
setLogger(logger: (msg: string) => void, errorLogger?: (msg: string) => void): Promise<Responses>; | ||
setLogger(logger: (msg: string) => void, errorLogger?: (msg: string) => void): Promise<Responses<LoggerResponse,LoggerResponseFailure>>; | ||
@@ -176,7 +274,11 @@ /** | ||
*/ | ||
shutdown(callback?: () => void): void; | ||
shutdown(callback?: () => void): Promise<void>; | ||
} | ||
export type NotificationPushType = 'background' | 'alert' | 'voip' | 'pushtotalk' | 'liveactivity'; | ||
export type NotificationPushType = 'background' | 'alert' | 'voip' | 'pushtotalk' | 'liveactivity' | 'location' | 'complication' | 'fileprovider' | 'mdm'; | ||
export type ChannelAction = 'create' | 'read' | 'readAll' | 'delete'; | ||
export type ApsNotificationInterruptionLevel = 'passive' | 'active' | 'time-sensitive' | 'critical'; | ||
export interface NotificationAlertOptions { | ||
@@ -211,17 +313,18 @@ title?: string; | ||
/** | ||
* The UNIX timestamp representing when the notification should expire. This does not contribute to the 2048 byte payload size limit. An expiry of 0 indicates that the notification expires immediately. | ||
* A UUID to identify this request. | ||
*/ | ||
public expiry: number; | ||
public requestId: string; | ||
/** | ||
* Multiple notifications with same collapse identifier are displayed to the user as a single notification. The value should not exceed 64 bytes. | ||
* A base64-encoded string that identifies the channel to publish the payload. | ||
The channel ID is generated by sending channel creation request to APNs. | ||
*/ | ||
public collapseId: string; | ||
public channelId: string; | ||
/** | ||
* Multiple notifications with same collapse identifier are displayed to the user as a single notification. The value should not exceed 64 bytes. | ||
*/ | ||
public requestId: string; | ||
public collapseId: string; | ||
/** | ||
* An optional custom request identifier that’s returned back in the response. The request identifier must be encoded as a UUID string. | ||
* The UNIX timestamp representing when the notification should expire. This does not contribute to the 2048 byte payload size limit. An expiry of 0 indicates that the notification expires immediately. | ||
*/ | ||
public channelId: string; | ||
public expiry: number; | ||
/** | ||
@@ -239,3 +342,2 @@ * Provide one of the following values: | ||
public pushType: NotificationPushType; | ||
/** | ||
@@ -242,0 +344,0 @@ * An app-specific identifier for grouping related notifications. |
@@ -21,5 +21,13 @@ const VError = require('verror'); | ||
HTTP2_METHOD_POST, | ||
HTTP2_METHOD_GET, | ||
HTTP2_METHOD_DELETE, | ||
NGHTTP2_CANCEL, | ||
} = http2.constants; | ||
const HTTPMethod = { | ||
post: HTTP2_METHOD_POST, | ||
get: HTTP2_METHOD_GET, | ||
delete: HTTP2_METHOD_DELETE, | ||
}; | ||
const TIMEOUT_STATUS = '(timeout)'; | ||
@@ -30,2 +38,3 @@ const ABORTED_STATUS = '(aborted)'; | ||
function Client(options) { | ||
this.isDestroyed = false; | ||
this.config = config(options); | ||
@@ -37,61 +46,299 @@ this.logger = defaultLogger; | ||
this.session.ping((error, duration) => { | ||
if (error) { | ||
if (error && this.errorLogger.enabled) { | ||
this.errorLogger( | ||
'No Ping response after ' + duration + ' ms with error:' + error.message | ||
); | ||
return; | ||
} else if (this.logger.enabled) { | ||
this.logger('Ping response after ' + duration + ' ms'); | ||
} | ||
this.logger('Ping response after ' + duration + ' ms'); | ||
}); | ||
} | ||
}, this.config.heartBeat).unref(); | ||
this.manageChannelsHealthCheckInterval = setInterval(() => { | ||
if ( | ||
this.manageChannelsSession && | ||
!this.manageChannelsSession.closed && | ||
!this.manageChannelsSession.destroyed && | ||
!this.isDestroyed | ||
) { | ||
this.manageChannelsSession.ping((error, duration) => { | ||
if (error && this.errorLogger.enabled) { | ||
this.errorLogger( | ||
'ManageChannelsSession No Ping response after ' + | ||
duration + | ||
' ms with error:' + | ||
error.message | ||
); | ||
} else if (this.logger.enabled) { | ||
this.logger('ManageChannelsSession Ping response after ' + duration + ' ms'); | ||
} | ||
}); | ||
} | ||
}, this.config.heartBeat).unref(); | ||
} | ||
// Session should be passed except when destroying the client | ||
Client.prototype.destroySession = function (session, callback) { | ||
// The respective session should always be passed. | ||
Client.prototype.destroySession = function (session) { | ||
if (!session) { | ||
session = this.session; | ||
return; | ||
} | ||
if (session) { | ||
if (this.session === session) { | ||
this.session = null; | ||
} | ||
if (!session.destroyed) { | ||
session.destroy(); | ||
} | ||
if (!session.destroyed) { | ||
session.destroy(); | ||
} | ||
if (callback) { | ||
callback(); | ||
} | ||
session = null; | ||
}; | ||
// Session should be passed except when destroying the client | ||
Client.prototype.closeAndDestroySession = function (session, callback) { | ||
// The respective session should always be passed. | ||
Client.prototype.closeAndDestroySession = async function (session) { | ||
if (!session) { | ||
session = this.session; | ||
return; | ||
} | ||
if (session) { | ||
if (this.session === session) { | ||
this.session = null; | ||
if (!session.closed) { | ||
await new Promise(resolve => { | ||
session.close(() => { | ||
resolve(); | ||
}); | ||
}); | ||
} | ||
this.destroySession(session); | ||
}; | ||
Client.prototype.makePath = function makePath(type, subDirectory) { | ||
switch (type) { | ||
case 'channels': | ||
return `/1/apps/${subDirectory}/channels`; | ||
case 'allChannels': | ||
return `/1/apps/${subDirectory}/all-channels`; | ||
case 'device': | ||
return `/3/device/${subDirectory}`; | ||
case 'broadcasts': | ||
return `/4/broadcasts/apps/${subDirectory}`; | ||
default: | ||
return null; | ||
} | ||
}; | ||
Client.prototype.subDirectoryLabel = function subDirectoryLabel(type) { | ||
switch (type) { | ||
case 'device': | ||
return 'device'; | ||
case 'channels': | ||
case 'allChannels': | ||
case 'broadcasts': | ||
return 'bundleId'; | ||
default: | ||
return null; | ||
} | ||
}; | ||
Client.prototype.makeSubDirectoryTypeObject = function makeSubDirectoryTypeObject( | ||
label, | ||
subDirectory | ||
) { | ||
const subDirectoryObject = {}; | ||
subDirectoryObject[label] = subDirectory; | ||
return subDirectoryObject; | ||
}; | ||
Client.prototype.write = async function write(notification, subDirectory, type, method, count) { | ||
const retryStatusCodes = [408, 429, 500, 502, 503, 504]; | ||
const retryCount = count || 0; | ||
const subDirectoryLabel = this.subDirectoryLabel(type) ?? type; | ||
const subDirectoryInformation = this.makeSubDirectoryTypeObject( | ||
subDirectoryLabel, | ||
subDirectory | ||
); | ||
const path = this.makePath(type, subDirectory); | ||
if (path == null) { | ||
const error = { | ||
...subDirectoryInformation, | ||
error: new VError(`could not make a path for ${type} and ${subDirectory}`), | ||
}; | ||
throw error; | ||
} | ||
const httpMethod = HTTPMethod[method]; | ||
if (httpMethod == null) { | ||
const error = { | ||
...subDirectoryInformation, | ||
error: new VError(`invalid httpMethod "${method}"`), | ||
}; | ||
throw error; | ||
} | ||
if (this.isDestroyed) { | ||
const error = { ...subDirectoryInformation, error: new VError('client is destroyed') }; | ||
throw error; | ||
} | ||
if (path.includes('/1/apps/')) { | ||
// Connect manageChannelsSession. | ||
if ( | ||
!this.manageChannelsSession || | ||
this.manageChannelsSession.closed || | ||
this.manageChannelsSession.destroyed | ||
) { | ||
try { | ||
await this.manageChannelsConnect(); | ||
} catch (error) { | ||
if (this.errorLogger.enabled) { | ||
// Proxy server that returned error doesn't have access to logger. | ||
this.errorLogger(error.message); | ||
} | ||
const updatedError = { ...subDirectoryInformation, error }; | ||
throw updatedError; | ||
} | ||
} | ||
if (!session.closed) { | ||
session.close(() => this.destroySession(session, callback)); | ||
} else { | ||
this.destroySession(session, callback); | ||
try { | ||
const sentRequest = await this.request( | ||
this.manageChannelsSession, | ||
this.config.manageChannelsAddress, | ||
notification, | ||
path, | ||
httpMethod | ||
); | ||
return { ...subDirectoryInformation, ...sentRequest }; | ||
} catch (error) { | ||
// Determine if this is a retryable request. | ||
if ( | ||
retryStatusCodes.includes(error.status) || | ||
(typeof error.error !== 'undefined' && | ||
error.status == 403 && | ||
error.error.message === 'ExpiredProviderToken') | ||
) { | ||
try { | ||
const resentRequest = await this.retryRequest( | ||
error, | ||
this.manageChannelsSession, | ||
this.config.manageChannelsAddress, | ||
notification, | ||
path, | ||
httpMethod, | ||
retryCount | ||
); | ||
return { ...subDirectoryInformation, ...resentRequest }; | ||
} catch (error) { | ||
if (error.status == 500) { | ||
await this.closeAndDestroySession(this.manageChannelsSession); | ||
} | ||
delete error.retryAfter; // Never propagate retryAfter outside of client. | ||
const updatedError = { ...subDirectoryInformation, ...error }; | ||
throw updatedError; | ||
} | ||
} else { | ||
delete error.retryAfter; // Never propagate retryAfter outside of client. | ||
throw { ...subDirectoryInformation, ...error }; | ||
} | ||
} | ||
} else if (callback) { | ||
callback(); | ||
} else { | ||
// Connect to standard session. | ||
if (!this.session || this.session.closed || this.session.destroyed) { | ||
try { | ||
await this.connect(); | ||
} catch (error) { | ||
if (this.errorLogger.enabled) { | ||
// Proxy server that returned error doesn't have access to logger. | ||
this.errorLogger(error.message); | ||
} | ||
delete error.retryAfter; // Never propagate retryAfter outside of client. | ||
const updatedError = { ...subDirectoryInformation, error }; | ||
throw updatedError; | ||
} | ||
} | ||
try { | ||
const sentRequest = await this.request( | ||
this.session, | ||
this.config.address, | ||
notification, | ||
path, | ||
httpMethod | ||
); | ||
return { ...subDirectoryInformation, ...sentRequest }; | ||
} catch (error) { | ||
// Determine if this is a retryable request. | ||
if ( | ||
retryStatusCodes.includes(error.status) || | ||
(typeof error.error !== 'undefined' && | ||
error.status == 403 && | ||
error.error.message === 'ExpiredProviderToken') | ||
) { | ||
try { | ||
const resentRequest = await this.retryRequest( | ||
error, | ||
this.session, | ||
this.config.address, | ||
notification, | ||
path, | ||
httpMethod, | ||
retryCount | ||
); | ||
return { ...subDirectoryInformation, ...resentRequest }; | ||
} catch (error) { | ||
if (error.status == 500) { | ||
await this.closeAndDestroySession(this.session); | ||
} | ||
delete error.retryAfter; // Never propagate retryAfter outside of client. | ||
const updatedError = { ...subDirectoryInformation, ...error }; | ||
throw updatedError; | ||
} | ||
} else { | ||
delete error.retryAfter; // Never propagate retryAfter outside of client. | ||
throw { ...subDirectoryInformation, ...error }; | ||
} | ||
} | ||
} | ||
}; | ||
Client.prototype.write = function write(notification, device, count) { | ||
if (this.isDestroyed) { | ||
return Promise.resolve({ device, error: new VError('client is destroyed') }); | ||
Client.prototype.retryRequest = async function retryRequest( | ||
error, | ||
session, | ||
address, | ||
notification, | ||
path, | ||
httpMethod, | ||
count | ||
) { | ||
if (this.isDestroyed || session.closed) { | ||
const error = { error: new VError('client session is either closed or destroyed') }; | ||
throw error; | ||
} | ||
// Connect session | ||
if (!this.session || this.session.closed || this.session.destroyed) { | ||
return this.connect().then(() => this.request(notification, device, count)); | ||
const retryCount = count + 1; | ||
if (retryCount > this.config.connectionRetryLimit) { | ||
throw error; | ||
} | ||
return this.request(notification, device, count); | ||
const delayInSeconds = parseInt(error.retryAfter || 0); | ||
// Obey servers request to try after a specific time in ms. | ||
const delayPromise = new Promise(resolve => setTimeout(resolve, delayInSeconds * 1000)); | ||
await delayPromise; | ||
try { | ||
const sentRequest = await this.request( | ||
session, | ||
address, | ||
notification, | ||
path, | ||
httpMethod, | ||
retryCount | ||
); | ||
return sentRequest; | ||
} catch (error) { | ||
// Recursivelly call self until retryCount is exhausted | ||
// or error is thrown. | ||
const sentRequest = await this.retryRequest( | ||
error, | ||
session, | ||
address, | ||
notification, | ||
path, | ||
httpMethod, | ||
retryCount | ||
); | ||
return sentRequest; | ||
} | ||
}; | ||
@@ -111,2 +358,3 @@ | ||
this.sessionPromise = null; | ||
if (socket) { | ||
@@ -130,2 +378,8 @@ this.config.createConnection = authority => | ||
if (this.logger.enabled) { | ||
this.session.on('connect', () => { | ||
this.logger('Session connected'); | ||
}); | ||
} | ||
this.session.on('close', () => { | ||
@@ -138,5 +392,5 @@ if (this.errorLogger.enabled) { | ||
this.session.on('socketError', error => { | ||
this.session.on('error', error => { | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger(`Socket error: ${error}`); | ||
this.errorLogger(`Session error: ${error}`); | ||
} | ||
@@ -146,5 +400,7 @@ this.closeAndDestroySession(session); | ||
this.session.on('error', error => { | ||
this.session.on('goaway', (errorCode, lastStreamId, opaqueData) => { | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger(`Session error: ${error}`); | ||
this.errorLogger( | ||
`GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})` | ||
); | ||
} | ||
@@ -154,6 +410,7 @@ this.closeAndDestroySession(session); | ||
this.session.on('goaway', (errorCode, lastStreamId, opaqueData) => { | ||
this.session.on('frameError', (frameType, errorCode, streamId) => { | ||
// This is a frame error not associate with any request(stream). | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger( | ||
`GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})` | ||
`Frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})` | ||
); | ||
@@ -163,13 +420,84 @@ } | ||
}); | ||
}); | ||
return this.sessionPromise; | ||
}; | ||
Client.prototype.manageChannelsConnect = async function manageChannelsConnect() { | ||
if (this.manageChannelsSessionPromise) return this.manageChannelsSessionPromise; | ||
const proxySocketPromise = this.config.manageChannelsProxy | ||
? createProxySocket(this.config.manageChannelsProxy, { | ||
host: this.config.manageChannelsAddress, | ||
port: this.config.manageChannelsPort, | ||
}) | ||
: Promise.resolve(); | ||
this.manageChannelsSessionPromise = proxySocketPromise.then(socket => { | ||
this.manageChannelsSessionPromise = null; | ||
if (socket) { | ||
this.config.createConnection = authority => | ||
authority.protocol === 'http:' | ||
? socket | ||
: authority.protocol === 'https:' | ||
? tls.connect(+authority.port || this.config.manageChannelsPort, authority.hostname, { | ||
socket, | ||
servername: authority.hostname, | ||
ALPNProtocols: ['h2'], | ||
}) | ||
: null; | ||
} | ||
const config = { ...this.config }; // Only need a shallow copy. | ||
// http2 will use this address and port. | ||
config.address = config.manageChannelsAddress; | ||
config.port = config.manageChannelsPort; | ||
const session = (this.manageChannelsSession = http2.connect( | ||
this._mockOverrideUrl || `https://${config.address}`, | ||
config | ||
)); | ||
if (this.logger.enabled) { | ||
this.session.on('connect', () => { | ||
this.logger('Session connected'); | ||
this.manageChannelsSession.on('connect', () => { | ||
this.logger('ManageChannelsSession connected'); | ||
}); | ||
} | ||
this.session.on('frameError', (frameType, errorCode, streamId) => { | ||
this.manageChannelsSession.on('close', () => { | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger('ManageChannelsSession closed'); | ||
} | ||
this.destroySession(session); | ||
}); | ||
this.manageChannelsSession.on('socketError', error => { | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger(`ManageChannelsSession Socket error: ${error}`); | ||
} | ||
this.closeAndDestroySession(session); | ||
}); | ||
this.manageChannelsSession.on('error', error => { | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger(`ManageChannelsSession error: ${error}`); | ||
} | ||
this.closeAndDestroySession(session); | ||
}); | ||
this.manageChannelsSession.on('goaway', (errorCode, lastStreamId, opaqueData) => { | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger( | ||
`ManageChannelsSession GOAWAY received: (errorCode ${errorCode}, lastStreamId: ${lastStreamId}, opaqueData: ${opaqueData})` | ||
); | ||
} | ||
this.closeAndDestroySession(session); | ||
}); | ||
this.manageChannelsSession.on('frameError', (frameType, errorCode, streamId) => { | ||
// This is a frame error not associate with any request(stream). | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger( | ||
`Frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})` | ||
`ManageChannelsSession Frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})` | ||
); | ||
@@ -181,10 +509,37 @@ } | ||
return this.sessionPromise; | ||
return this.manageChannelsSessionPromise; | ||
}; | ||
Client.prototype.request = function request(notification, device, count) { | ||
Client.prototype.createHeaderObject = function createHeaderObject( | ||
uniqueId, | ||
requestId, | ||
channelId | ||
) { | ||
const header = {}; | ||
if (uniqueId) { | ||
header['apns-unique-id'] = uniqueId; | ||
} | ||
if (requestId) { | ||
header['apns-request-id'] = requestId; | ||
} | ||
if (channelId) { | ||
header['apns-channel-id'] = channelId; | ||
} | ||
return header; | ||
}; | ||
Client.prototype.request = async function request( | ||
session, | ||
address, | ||
notification, | ||
path, | ||
httpMethod | ||
) { | ||
let tokenGeneration = null; | ||
let status = null; | ||
let retryAfter = null; | ||
let uniqueId = null; | ||
let requestId = null; | ||
let channelId = null; | ||
let responseData = ''; | ||
const retryCount = count || 0; | ||
@@ -194,5 +549,5 @@ const headers = extend( | ||
[HTTP2_HEADER_SCHEME]: 'https', | ||
[HTTP2_HEADER_METHOD]: HTTP2_METHOD_POST, | ||
[HTTP2_HEADER_AUTHORITY]: this.config.address, | ||
[HTTP2_HEADER_PATH]: `/3/device/${device}`, | ||
[HTTP2_HEADER_METHOD]: httpMethod, | ||
[HTTP2_HEADER_AUTHORITY]: address, | ||
[HTTP2_HEADER_PATH]: path, | ||
}, | ||
@@ -210,3 +565,3 @@ notification.headers | ||
const request = this.session.request(headers); | ||
const request = session.request(headers); | ||
@@ -217,2 +572,6 @@ request.setEncoding('utf8'); | ||
status = headers[HTTP2_HEADER_STATUS]; | ||
retryAfter = headers['Retry-After']; | ||
uniqueId = headers['apns-unique-id']; | ||
requestId = headers['apns-request-id']; | ||
channelId = headers['apns-channel-id']; | ||
}); | ||
@@ -224,5 +583,7 @@ | ||
request.write(notification.body); | ||
if (Object.keys(notification.body).length > 0) { | ||
request.write(notification.body); | ||
} | ||
return new Promise(resolve => { | ||
return new Promise((resolve, reject) => { | ||
request.on('end', () => { | ||
@@ -233,6 +594,15 @@ try { | ||
} | ||
const headerObject = this.createHeaderObject(uniqueId, requestId, channelId); | ||
if (status === 200) { | ||
resolve({ device }); | ||
if (status === 200 || status === 201 || status === 204) { | ||
const body = responseData !== '' ? JSON.parse(responseData) : {}; | ||
resolve({ ...headerObject, ...body }); | ||
return; | ||
} else if ([TIMEOUT_STATUS, ABORTED_STATUS, ERROR_STATUS].includes(status)) { | ||
const error = { | ||
status, | ||
retryAfter, | ||
error: new VError('Timeout, aborted, or other unknown error'), | ||
}; | ||
reject({ ...headerObject, ...error }); | ||
return; | ||
@@ -242,20 +612,26 @@ } else if (responseData !== '') { | ||
if (status === 403 && response.reason === 'ExpiredProviderToken' && retryCount < 2) { | ||
if (status === 403 && response.reason === 'ExpiredProviderToken') { | ||
this.config.token.regenerate(tokenGeneration); | ||
resolve(this.write(notification, device, retryCount + 1)); | ||
const error = { | ||
status, | ||
retryAfter, | ||
error: new VError(response.reason), | ||
}; | ||
reject({ ...headerObject, ...error }); | ||
return; | ||
} else if (status === 500 && response.reason === 'InternalServerError') { | ||
this.closeAndDestroySession(); | ||
const error = new VError('Error 500, stream ended unexpectedly'); | ||
resolve({ device, error }); | ||
const error = { | ||
status, | ||
retryAfter, | ||
error: new VError('Error 500, stream ended unexpectedly'), | ||
}; | ||
reject({ ...headerObject, ...error }); | ||
return; | ||
} | ||
resolve({ device, status, response }); | ||
reject({ ...headerObject, status, retryAfter, response }); | ||
} else { | ||
this.closeAndDestroySession(); | ||
const error = new VError( | ||
`stream ended unexpectedly with status ${status} and empty body` | ||
); | ||
resolve({ device, error }); | ||
const error = { | ||
error: new VError(`stream ended unexpectedly with status ${status} and empty body`), | ||
}; | ||
reject({ ...headerObject, ...error }); | ||
} | ||
@@ -267,3 +643,3 @@ } catch (e) { | ||
} | ||
resolve({ device, error }); | ||
reject({ error }); | ||
} | ||
@@ -281,3 +657,4 @@ }); | ||
resolve({ device, error: new VError('apn write timeout') }); | ||
const error = { error: new VError('apn write timeout') }; | ||
reject(error); | ||
}); | ||
@@ -292,3 +669,4 @@ | ||
resolve({ device, error: new VError('apn write aborted') }); | ||
const error = { error: new VError('apn write aborted') }; | ||
reject(error); | ||
}); | ||
@@ -309,12 +687,13 @@ | ||
resolve({ device, error }); | ||
reject({ error }); | ||
}); | ||
if (this.errorLogger.enabled) { | ||
request.on('frameError', (frameType, errorCode, streamId) => { | ||
this.errorLogger( | ||
`Request frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})` | ||
); | ||
}); | ||
} | ||
request.on('frameError', (frameType, errorCode, streamId) => { | ||
const errorMessage = `Request frame error: (frameType: ${frameType}, errorCode ${errorCode}, streamId: ${streamId})`; | ||
if (this.errorLogger.enabled) { | ||
this.errorLogger(errorMessage); | ||
} | ||
const error = new VError(errorMessage); | ||
reject({ error }); | ||
}); | ||
@@ -325,3 +704,3 @@ request.end(); | ||
Client.prototype.shutdown = function shutdown(callback) { | ||
Client.prototype.shutdown = async function shutdown(callback) { | ||
if (this.isDestroyed) { | ||
@@ -341,3 +720,12 @@ if (callback) { | ||
} | ||
this.closeAndDestroySession(undefined, callback); | ||
if (this.manageChannelsHealthCheckInterval) { | ||
clearInterval(this.manageChannelsHealthCheckInterval); | ||
this.manageChannelsHealthCheckInterval = null; | ||
} | ||
await this.closeAndDestroySession(this.session); | ||
await this.closeAndDestroySession(this.manageChannelsSession); | ||
if (callback) { | ||
callback(); | ||
} | ||
}; | ||
@@ -344,0 +732,0 @@ |
@@ -8,2 +8,7 @@ const extend = require('./util/extend'); | ||
const ManageChannelsEndpointAddress = { | ||
production: 'api-manage-broadcast.push.apple.com', | ||
development: 'api-manage-broadcast.sandbox.push.apple.com', | ||
}; | ||
module.exports = function (dependencies) { | ||
@@ -26,5 +31,8 @@ const logger = dependencies.logger; | ||
port: 443, | ||
manageChannelsAddress: null, | ||
manageChannelsPort: null, | ||
proxy: null, | ||
manageChannelsProxy: null, | ||
rejectUnauthorized: true, | ||
connectionRetryLimit: 10, | ||
connectionRetryLimit: 3, | ||
heartBeat: 60000, | ||
@@ -38,2 +46,3 @@ requestTimeout: 5000, | ||
configureAddress(config); | ||
configureManageChannelsAddress(config); | ||
@@ -111,1 +120,22 @@ if (config.token) { | ||
} | ||
function configureManageChannelsAddress(options) { | ||
if (!options.manageChannelsAddress) { | ||
if (options.production) { | ||
options.manageChannelsAddress = ManageChannelsEndpointAddress.production; | ||
} else { | ||
options.manageChannelsAddress = ManageChannelsEndpointAddress.development; | ||
} | ||
} | ||
configureManageChannelsPort(options); | ||
} | ||
function configureManageChannelsPort(options) { | ||
if (!options.manageChannelsPort) { | ||
if (options.production) { | ||
options.manageChannelsPort = 2196; | ||
} else { | ||
options.manageChannelsPort = 2195; | ||
} | ||
} | ||
} |
@@ -33,7 +33,13 @@ module.exports = function (dependencies) { | ||
MultiClient.prototype.write = function write(notification, device, count) { | ||
return this.chooseSingleClient().write(notification, device, count); | ||
MultiClient.prototype.write = async function write( | ||
notification, | ||
subDirectory, | ||
type, | ||
method, | ||
count | ||
) { | ||
return await this.chooseSingleClient().write(notification, subDirectory, type, method, count); | ||
}; | ||
MultiClient.prototype.shutdown = function shutdown(callback) { | ||
MultiClient.prototype.shutdown = async function shutdown(callback) { | ||
let callCount = 0; | ||
@@ -43,6 +49,10 @@ const multiCallback = () => { | ||
if (callCount === this.clients.length) { | ||
callback(); | ||
if (callback) { | ||
callback(); | ||
} | ||
} | ||
}; | ||
this.clients.forEach(client => client.shutdown(multiCallback)); | ||
for (const client of this.clients) { | ||
await client.shutdown(multiCallback); | ||
} | ||
}; | ||
@@ -49,0 +59,0 @@ |
@@ -106,3 +106,23 @@ /** | ||
Notification.prototype.removeNonChannelRelatedProperties = function () { | ||
this.priority = 10; | ||
this.id = undefined; | ||
this.collapseId = undefined; | ||
this.topic = undefined; | ||
this.pushType = undefined; | ||
}; | ||
/** | ||
* Add live activity push type if it's not already provided. | ||
* | ||
* @remarks | ||
* LiveActivity is the only current type supported. | ||
*/ | ||
Notification.prototype.addPushTypeToPayloadIfNeeded = function () { | ||
if (this.rawPayload == undefined && this.payload['push-type'] == undefined) { | ||
this.payload['push-type'] = 'liveactivity'; | ||
} | ||
}; | ||
/** | ||
* Compile a notification down to its JSON format. Compilation is final, changes made to the notification after this method is called will not be reflected in further calls. | ||
@@ -109,0 +129,0 @@ * @returns {String} JSON payload for the notification. |
const EventEmitter = require('events'); | ||
const VError = require('verror'); | ||
@@ -18,3 +19,3 @@ module.exports = function (dependencies) { | ||
Provider.prototype.send = function send(notification, recipients) { | ||
Provider.prototype.send = async function send(notification, recipients) { | ||
const builtNotification = { | ||
@@ -29,19 +30,131 @@ headers: notification.headers(), | ||
return Promise.all(recipients.map(token => this.client.write(builtNotification, token))).then( | ||
responses => { | ||
const sent = []; | ||
const failed = []; | ||
const sentNotifications = await Promise.allSettled( | ||
recipients.map( | ||
async token => await this.client.write(builtNotification, token, 'device', 'post') | ||
) | ||
); | ||
const sent = []; | ||
const failed = []; | ||
responses.forEach(response => { | ||
if (response.status || response.error) { | ||
failed.push(response); | ||
} else { | ||
sent.push(response); | ||
} | ||
}); | ||
return { sent, failed }; | ||
sentNotifications.forEach(sentNotification => { | ||
if (sentNotification.status == 'fulfilled') { | ||
const sentNotificationValue = sentNotification.value; | ||
if (sentNotificationValue.status || sentNotificationValue.error) { | ||
failed.push(sentNotificationValue); | ||
} else { | ||
sent.push(sentNotificationValue); | ||
} | ||
} else { | ||
failed.push(sentNotification.reason); | ||
} | ||
}); | ||
return { sent, failed }; | ||
}; | ||
Provider.prototype.manageChannels = async function manageChannels( | ||
notifications, | ||
bundleId, | ||
action | ||
) { | ||
let type = 'channels'; | ||
let method = 'post'; | ||
if (!Array.isArray(notifications)) { | ||
notifications = [notifications]; | ||
} | ||
switch (action) { | ||
case 'create': | ||
type = 'channels'; | ||
method = 'post'; | ||
break; | ||
case 'read': | ||
type = 'channels'; | ||
method = 'get'; | ||
break; | ||
case 'readAll': | ||
type = 'allChannels'; | ||
method = 'get'; | ||
break; | ||
case 'delete': | ||
type = 'channels'; | ||
method = 'delete'; | ||
break; | ||
default: { | ||
const error = { | ||
bundleId, | ||
error: new VError(`the action "${action}" is not supported`), | ||
}; | ||
throw error; | ||
} | ||
} | ||
const sentNotifications = await Promise.allSettled( | ||
notifications.map(async notification => { | ||
if (action == 'create') { | ||
notification.addPushTypeToPayloadIfNeeded(); | ||
} | ||
notification.removeNonChannelRelatedProperties(); | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
return await this.client.write(builtNotification, bundleId, type, method); | ||
}) | ||
); | ||
const sent = []; | ||
const failed = []; | ||
sentNotifications.forEach(sentNotification => { | ||
if (sentNotification.status == 'fulfilled') { | ||
const sentNotificationValue = sentNotification.value; | ||
if (sentNotificationValue.status || sentNotificationValue.error) { | ||
failed.push(sentNotificationValue); | ||
} else { | ||
sent.push(sentNotificationValue); | ||
} | ||
} else { | ||
failed.push(sentNotification.reason); | ||
} | ||
}); | ||
return { sent, failed }; | ||
}; | ||
Provider.prototype.broadcast = async function broadcast(notifications, bundleId) { | ||
if (!Array.isArray(notifications)) { | ||
notifications = [notifications]; | ||
} | ||
const sentNotifications = await Promise.allSettled( | ||
notifications.map(async notification => { | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
return await this.client.write(builtNotification, bundleId, 'broadcasts', 'post'); | ||
}) | ||
); | ||
const sent = []; | ||
const failed = []; | ||
sentNotifications.forEach(sentNotification => { | ||
if (sentNotification.status == 'fulfilled') { | ||
const sentNotificationValue = sentNotification.value; | ||
if (sentNotificationValue.status || sentNotificationValue.error) { | ||
failed.push(sentNotificationValue); | ||
} else { | ||
sent.push(sentNotificationValue); | ||
} | ||
} else { | ||
failed.push(sentNotification.reason); | ||
} | ||
}); | ||
return { sent, failed }; | ||
}; | ||
Provider.prototype.shutdown = function shutdown(callback) { | ||
@@ -48,0 +161,0 @@ this.client.shutdown(callback); |
const http = require('http'); | ||
const VError = require('verror'); | ||
@@ -12,3 +13,7 @@ module.exports = function createProxySocket(proxy, target) { | ||
}); | ||
req.on('error', reject); | ||
req.on('error', error => { | ||
const connectionError = new VError(`cannot connect to proxy server: ${error}`); | ||
const returnedError = { error: connectionError }; | ||
reject(returnedError); | ||
}); | ||
req.on('connect', (res, socket, head) => { | ||
@@ -15,0 +20,0 @@ resolve(socket); |
@@ -5,3 +5,3 @@ module.exports = function () { | ||
Client.prototype.write = function mockWrite(notification, device) { | ||
Client.prototype.write = function mockWrite(notification, device, type, method = 'post') { | ||
return { device }; | ||
@@ -8,0 +8,0 @@ }; |
{ | ||
"name": "@parse/node-apn", | ||
"description": "An interface to the Apple Push Notification service for Node.js", | ||
"version": "6.3.0", | ||
"version": "6.4.0", | ||
"author": "Parse Platform, Andrew Naylor <argon@mkbot.net>", | ||
@@ -6,0 +6,0 @@ "keywords": [ |
143
README.md
# Node APN <!-- omit in toc --> | ||
[![Build Status](https://github.com/parse-community/node-apn/workflows/ci/badge.svg?branch=master)](https://github.com/parse-community/parse-server-push-adapter/actions?query=workflow%3Aci+branch%3Amaster) | ||
[![Build Status](https://github.com/parse-community/node-apn/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/parse-community/node-apn/actions/workflows/ci.yml?query=workflow%3Aci+branch%3Amaster) | ||
[![Snyk Badge](https://snyk.io/test/github/parse-community/node-apn/badge.svg)](https://snyk.io/test/github/parse-community/parse-server-push-adapter) | ||
[![Coverage](https://img.shields.io/codecov/c/github/parse-community/node-apn/master.svg)](https://codecov.io/github/parse-community/parse-server-push-adapter?branch=master) | ||
[![Coverage](https://codecov.io/github/parse-community/node-apn/branch/master/graph/badge.svg)](https://app.codecov.io/github/parse-community/node-apn/tree/master) | ||
[![auto-release](https://img.shields.io/badge/%F0%9F%9A%80-auto--release-9e34eb.svg)](https://github.com/parse-community/node-apn/releases) | ||
@@ -24,2 +24,4 @@ | ||
- [Sending a notification](#sending-a-notification) | ||
- [Managing channels](#manage-channels) | ||
- [Sending a broadcast notification](#sending-a-broadcast-notification) | ||
@@ -40,3 +42,3 @@ # Features | ||
This readme is a brief introduction, please refer to the full [documentation](doc/apn.markdown) in `doc/` for more details. | ||
This readme is a brief introduction; please refer to the full [documentation](doc/apn.markdown) in `doc/` for more details. | ||
@@ -64,3 +66,3 @@ If you have previously used v1.x and wish to learn more about what's changed in v2.0, please see [What's New](doc/whats-new.markdown) | ||
var apnProvider = new apn.Provider(options); | ||
const apnProvider = new apn.Provider(options); | ||
``` | ||
@@ -70,6 +72,11 @@ | ||
For more information about configuration options consult the [provider documentation](doc/provider.markdown). | ||
For more information about configuration options, consult the [provider documentation](doc/provider.markdown). | ||
Help with preparing the key and certificate files for connection can be found in the [wiki][certificateWiki] | ||
> [!WARNING] | ||
> You should only create one `Provider` per-process for each certificate/key pair you have. You do not need to create a new `Provider` for each notification. If you are only sending notifications to one app, there is no need for more than one `Provider`. | ||
> | ||
> If you are constantly creating `Provider` instances in your app, make sure to call `Provider.shutdown()` when you are done with each provider to release its resources and memory. | ||
### Connecting through an HTTP proxy | ||
@@ -93,3 +100,3 @@ | ||
var apnProvider = new apn.Provider(options); | ||
const apnProvider = new apn.Provider(options); | ||
``` | ||
@@ -119,7 +126,7 @@ | ||
var apnProvider = new apn.MultiProvider(options); | ||
const apnProvider = new apn.MultiProvider(options); | ||
``` | ||
## Sending a notification | ||
To send a notification you will first need a device token from your app as a string | ||
To send a notification, you will first need a device token from your app as a string. | ||
@@ -133,3 +140,3 @@ ```javascript | ||
```javascript | ||
var note = new apn.Notification(); | ||
let note = new apn.Notification(); | ||
@@ -147,8 +154,11 @@ note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. | ||
```javascript | ||
apnProvider.send(note, deviceToken).then( (result) => { | ||
try { | ||
const result = apnProvider.send(note, deviceToken); | ||
// see documentation for an explanation of result | ||
}); | ||
} catch(error) { | ||
// Handle error... | ||
} | ||
``` | ||
This will result in the the following notification payload being sent to the device | ||
This will result in the following notification payload being sent to the device. | ||
@@ -159,8 +169,10 @@ ```json | ||
Create a Live Activity notification object, configuring it with the relevant parameters (See the [notification documentation](doc/notification.markdown) for more details.) | ||
Create a Live Activity notification object and configure it with the relevant parameters (See the [notification documentation](doc/notification.markdown) for more details.) | ||
```javascript | ||
var note = new apn.Notification(); | ||
let note = new apn.Notification(); | ||
note.topic = "<your-app-bundle-id>.push-type.liveactivity"; | ||
note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. | ||
note.pushType = "liveactivity", | ||
note.badge = 3; | ||
@@ -170,4 +182,2 @@ note.sound = "ping.aiff"; | ||
note.payload = {'messageFrom': 'John Appleseed'}; | ||
note.topic = "<your-app-bundle-id>"; | ||
note.pushType = "liveactivity", | ||
note.relevanceScore = 75, | ||
@@ -183,8 +193,11 @@ note.timestamp = Math.floor(Date.now() / 1000); // Current time | ||
```javascript | ||
apnProvider.send(note, deviceToken).then( (result) => { | ||
// see documentation for an explanation of result | ||
}); | ||
try { | ||
const result = await apnProvider.send(note, deviceToken); | ||
// see the documentation for an explanation of the result | ||
} catch (error) { | ||
// Handle error... | ||
} | ||
``` | ||
This will result in the the following notification payload being sent to the device | ||
This will result in the following notification payload being sent to the device. | ||
@@ -196,4 +209,90 @@ | ||
You should only create one `Provider` per-process for each certificate/key pair you have. You do not need to create a new `Provider` for each notification. If you are only sending notifications to one app then there is no need for more than one `Provider`. | ||
## Manage Channels | ||
Starting in iOS 18 and iPadOS 18 Live Activities can be used to broadcast push notifications over channels. To do so, you will need your apps' `bundleId`. | ||
If you are constantly creating `Provider` instances in your app, make sure to call `Provider.shutdown()` when you are done with each provider to release its resources and memory. | ||
```javascript | ||
let bundleId = "com.node.apn"; | ||
``` | ||
Create a notification object, configuring it with the relevant parameters (See the [notification documentation](doc/notification.markdown) for more details.) | ||
```javascript | ||
let note = new apn.Notification(); | ||
note.requestId = "0309F412-AA57-46A8-9AC6-B5AECA8C4594"; // Optional | ||
note.payload = {'message-storage-policy': '1', 'push-type': 'liveactivity'}; // Required | ||
``` | ||
Create a channel with `manageChannels` and the `create` action, which returns a promise. | ||
```javascript | ||
try { | ||
const result = await apnProvider.manageChannels(note, bundleId, 'create'); | ||
// see the documentation for an explanation of the result | ||
} catch (error) { | ||
// Handle error... | ||
} | ||
``` | ||
If the channel is created successfully, the result will look like the following: | ||
```javascript | ||
{ | ||
apns-request-id: '0309F412-AA57-46A8-9AC6-B5AECA8C4594', | ||
apns-channel-id: 'dHN0LXNyY2gtY2hubA==' // The new channel | ||
} | ||
``` | ||
Similarly, `manageChannels` has additional `action`s that allow you to `read`, `readAll`, and `delete` channels. The `read` and `delete` actions require similar information to the `create` example above, with the exception that they require `note.channelId` to be populated. To request all active channel id's, you can use the `readAll` action: | ||
```javascript | ||
try { | ||
const result = await apnProvider.manageChannels(note, bundleId, 'readAll'); | ||
// see the documentation for an explanation of the result | ||
} catch (error) { | ||
// Handle error... | ||
} | ||
``` | ||
After the promise is fulfilled, `result` will look like the following: | ||
```javascript | ||
{ | ||
apns-request-id: 'some id value', | ||
channels: ['dHN0LXNyY2gtY2hubA==', 'eCN0LXNyY2gtY2hubA==' ...] // A list of active channels | ||
} | ||
``` | ||
Further information about managing channels can be found in [Apple's documentation](https://developer.apple.com/documentation/usernotifications/sending-channel-management-requests-to-apns). | ||
## Sending A Broadcast Notification | ||
Starting in iOS 18 and iPadOS 18, after a channel is created using `manageChannels`, broadcast push notifications can be sent to any device subscribed to the respective `channelId` created for a `bundleId`. A broadcast notification looks similar to a standard Live Activity notification mentioned above but requires `note.channelId` to be populated. An example is below: | ||
```javascript | ||
let note = new apn.Notification(); | ||
note.channelId = "dHN0LXNyY2gtY2hubA=="; // Required | ||
note.expiry = Math.floor(Date.now() / 1000) + 3600; // Expires 1 hour from now. | ||
note.pushType = "liveactivity", | ||
note.badge = 3; | ||
note.sound = "ping.aiff"; | ||
note.alert = "\uD83D\uDCE7 \u2709 You have a new message"; | ||
note.payload = {'messageFrom': 'John Appleseed'}; | ||
note.relevanceScore = 75, | ||
note.timestamp = Math.floor(Date.now() / 1000); // Current time | ||
note.staleDate = Math.floor(Date.now() / 1000) + (8 * 3600); // Expires 8 hour from now. | ||
note.event = "update" | ||
note.contentState = {} | ||
``` | ||
Send the broadcast notification to the API with `broadcast`, which returns a promise. | ||
```javascript | ||
try { | ||
const result = await apnProvider.broadcast(note, bundleId); | ||
// see documentation for an explanation of result | ||
} catch (error) { | ||
// Handle error... | ||
} | ||
``` | ||
Further information about broadcast notifications can be found in [Apple's documentation](https://developer.apple.com/documentation/usernotifications/sending-broadcast-push-notification-requests-to-apns). |
@@ -28,5 +28,8 @@ const sinon = require('sinon'); | ||
port: 443, | ||
manageChannelsAddress: 'api-manage-broadcast.sandbox.push.apple.com', | ||
manageChannelsPort: 2195, | ||
proxy: null, | ||
manageChannelsProxy: null, | ||
rejectUnauthorized: true, | ||
connectionRetryLimit: 10, | ||
connectionRetryLimit: 3, | ||
heartBeat: 60000, | ||
@@ -92,2 +95,75 @@ requestTimeout: 5000, | ||
describe('manageChannelsAddress configuration', function () { | ||
let originalEnv; | ||
before(function () { | ||
originalEnv = process.env.NODE_ENV; | ||
}); | ||
after(function () { | ||
process.env.NODE_ENV = originalEnv; | ||
}); | ||
beforeEach(function () { | ||
process.env.NODE_ENV = ''; | ||
}); | ||
it('should use api-manage-broadcast.sandbox.push.apple.com as the default connection address', function () { | ||
const testConfig = config(); | ||
expect(testConfig).to.have.property( | ||
'manageChannelsAddress', | ||
'api-manage-broadcast.sandbox.push.apple.com' | ||
); | ||
expect(testConfig).to.have.property('manageChannelsPort', 2195); | ||
}); | ||
it('should use api-manage-broadcast.push.apple.com when NODE_ENV=production', function () { | ||
process.env.NODE_ENV = 'production'; | ||
const testConfig = config(); | ||
expect(testConfig).to.have.property( | ||
'manageChannelsAddress', | ||
'api-manage-broadcast.push.apple.com' | ||
); | ||
expect(testConfig).to.have.property('manageChannelsPort', 2196); | ||
}); | ||
it('should give precedence to production flag over NODE_ENV=production', function () { | ||
process.env.NODE_ENV = 'production'; | ||
const testConfig = config({ production: false }); | ||
expect(testConfig).to.have.property( | ||
'manageChannelsAddress', | ||
'api-manage-broadcast.sandbox.push.apple.com' | ||
); | ||
expect(testConfig).to.have.property('manageChannelsPort', 2195); | ||
}); | ||
it('should use api-manage-broadcast.push.apple.com when production:true', function () { | ||
const testConfig = config({ production: true }); | ||
expect(testConfig).to.have.property( | ||
'manageChannelsAddress', | ||
'api-manage-broadcast.push.apple.com' | ||
); | ||
expect(testConfig).to.have.property('manageChannelsPort', 2196); | ||
}); | ||
it('should use a custom address and default port when passed', function () { | ||
const testAddress = 'testaddress'; | ||
const testPort = 2195; | ||
const testConfig = config({ manageChannelsAddress: testAddress }); | ||
expect(testConfig).to.have.property('manageChannelsAddress', testAddress); | ||
expect(testConfig).to.have.property('manageChannelsPort', testPort); | ||
}); | ||
it('should use a custom address and port when passed', function () { | ||
const testAddress = 'testaddress'; | ||
const testPort = 445; | ||
const testConfig = config({ | ||
manageChannelsAddress: testAddress, | ||
manageChannelsPort: testPort, | ||
}); | ||
expect(testConfig).to.have.property('manageChannelsAddress', testAddress); | ||
expect(testConfig).to.have.property('manageChannelsPort', testPort); | ||
}); | ||
}); | ||
describe('credentials', function () { | ||
@@ -94,0 +170,0 @@ context('`token` not supplied, use certificate', function () { |
@@ -112,2 +112,47 @@ const Notification = require('../../lib/notification'); | ||
describe('addPushTypeToPayloadIfNeeded', function () { | ||
it('add liveactivity push-type to payload when it is missing', function () { | ||
note.addPushTypeToPayloadIfNeeded(); | ||
expect(note.payload).to.deep.equal({ 'push-type': 'liveactivity' }); | ||
}); | ||
it('do not overwrite push-type if it is already present', function () { | ||
note.payload['push-type'] = 'alert'; | ||
note.addPushTypeToPayloadIfNeeded(); | ||
expect(note.payload).to.deep.equal({ 'push-type': 'alert' }); | ||
}); | ||
it('do not add push-type if rawPayload is present', function () { | ||
const payload = { some: 'payload' }; | ||
note = new Notification({ rawPayload: payload }); | ||
note.addPushTypeToPayloadIfNeeded(); | ||
expect(note.rawPayload).to.deep.equal({ some: 'payload' }); | ||
expect(compiledOutput()).to.deep.equal({ some: 'payload' }); | ||
}); | ||
}); | ||
describe('removeNonChannelRelatedProperties', function () { | ||
it('headers only contains channel related properties', function () { | ||
note.priority = 5; | ||
note.id = '123e4567-e89b-12d3-a456-42665544000'; | ||
note.pushType = 'alert'; | ||
note.expiry = 1000; | ||
note.topic = 'io.apn.node'; | ||
note.collapseId = 'io.apn.collapse'; | ||
note.requestId = 'io.apn.request'; | ||
note.channelId = 'io.apn.channel'; | ||
note.pushType = 'liveactivity'; | ||
note.removeNonChannelRelatedProperties(); | ||
expect(note.headers()).to.deep.equal({ | ||
'apns-channel-id': 'io.apn.channel', | ||
'apns-expiration': 1000, | ||
'apns-request-id': 'io.apn.request', | ||
}); | ||
}); | ||
}); | ||
describe('headers', function () { | ||
@@ -114,0 +159,0 @@ it('contains no properties by default', function () { |
@@ -44,8 +44,8 @@ const sinon = require('sinon'); | ||
describe('send', function () { | ||
describe('single notification behaviour', function () { | ||
describe('send', async () => { | ||
describe('single notification behaviour', async () => { | ||
let provider; | ||
context('transmission succeeds', function () { | ||
beforeEach(function () { | ||
context('transmission succeeds', async () => { | ||
beforeEach(async () => { | ||
provider = new Provider({ address: 'testapi' }); | ||
@@ -56,28 +56,28 @@ | ||
it('invokes the writer with correct `this`', function () { | ||
return provider.send(notificationDouble(), 'abcd1234').then(function () { | ||
expect(fakes.client.write).to.be.calledOn(fakes.client); | ||
}); | ||
it('invokes the writer with correct `this`', async () => { | ||
await provider.send(notificationDouble(), 'abcd1234'); | ||
expect(fakes.client.write).to.be.calledOn(fakes.client); | ||
}); | ||
it('writes the notification to the client once', function () { | ||
return provider.send(notificationDouble(), 'abcd1234').then(function () { | ||
const notification = notificationDouble(); | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
expect(fakes.client.write).to.be.calledOnce; | ||
expect(fakes.client.write).to.be.calledWith(builtNotification, 'abcd1234'); | ||
}); | ||
it('writes the notification to the client once', async () => { | ||
await provider.send(notificationDouble(), 'abcd1234'); | ||
const notification = notificationDouble(); | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
const device = 'abcd1234'; | ||
expect(fakes.client.write).to.be.calledOnce; | ||
expect(fakes.client.write).to.be.calledWith(builtNotification, device, 'device', 'post'); | ||
}); | ||
it('does not pass the array index to writer', function () { | ||
return provider.send(notificationDouble(), 'abcd1234').then(function () { | ||
expect(fakes.client.write.firstCall.args[2]).to.be.undefined; | ||
}); | ||
it('does not pass the array index to writer', async () => { | ||
await provider.send(notificationDouble(), 'abcd1234'); | ||
expect(fakes.client.write.firstCall.args[4]).to.be.undefined; | ||
}); | ||
it('resolves with the device token in the sent array', function () { | ||
return expect(provider.send(notificationDouble(), 'abcd1234')).to.become({ | ||
it('resolves with the device token in the sent array', async () => { | ||
const result = await provider.send(notificationDouble(), 'abcd1234'); | ||
expect(result).to.deep.equal({ | ||
sent: [{ device: 'abcd1234' }], | ||
@@ -89,6 +89,4 @@ failed: [], | ||
context('error occurs', function () { | ||
let promise; | ||
beforeEach(function () { | ||
context('error occurs', async () => { | ||
it('resolves with the device token, status code and response in the failed array', async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
@@ -103,7 +101,23 @@ | ||
); | ||
promise = provider.send(notificationDouble(), 'abcd1234'); | ||
const result = await provider.send(notificationDouble(), 'abcd1234'); | ||
expect(result).to.deep.equal({ | ||
sent: [], | ||
failed: [{ device: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }], | ||
}); | ||
}); | ||
it('resolves with the device token, status code and response in the failed array', function () { | ||
return expect(promise).to.eventually.deep.equal({ | ||
it('rejects with the device token, status code and response in the failed array', async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
fakes.client.write.onCall(0).returns( | ||
Promise.reject({ | ||
device: 'abcd1234', | ||
status: '400', | ||
response: { reason: 'BadDeviceToken' }, | ||
}) | ||
); | ||
const result = await provider.send(notificationDouble(), 'abcd1234'); | ||
expect(result).to.deep.equal({ | ||
sent: [], | ||
@@ -116,4 +130,4 @@ failed: [{ device: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }], | ||
context('when multiple tokens are passed', function () { | ||
beforeEach(function () { | ||
context('when multiple tokens are passed', async () => { | ||
beforeEach(async () => { | ||
fakes.resolutions = [ | ||
@@ -133,6 +147,6 @@ { device: 'abcd1234' }, | ||
context('streams are always returned', function () { | ||
let promise; | ||
context('streams are always returned', async () => { | ||
let response; | ||
beforeEach(function () { | ||
beforeEach(async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
@@ -144,41 +158,465 @@ | ||
promise = provider.send( | ||
response = await provider.send( | ||
notificationDouble(), | ||
fakes.resolutions.map(res => res.device) | ||
); | ||
}); | ||
return promise; | ||
it('resolves with the sent notifications', async () => { | ||
expect(response.sent).to.deep.equal([{ device: 'abcd1234' }, { device: 'bcfe4433' }]); | ||
}); | ||
it('resolves with the sent notifications', function () { | ||
return promise.then(response => { | ||
expect(response.sent).to.deep.equal([{ device: 'abcd1234' }, { device: 'bcfe4433' }]); | ||
it('resolves with the device token, status code and response or error of the unsent notifications', async () => { | ||
expect(response.failed[3].error).to.be.an.instanceof(Error); | ||
response.failed[3].error = { message: response.failed[3].error.message }; | ||
expect(response.failed).to.deep.equal( | ||
[ | ||
{ device: 'adfe5969', status: '400', response: { reason: 'MissingTopic' } }, | ||
{ | ||
device: 'abcd1335', | ||
status: '410', | ||
response: { reason: 'BadDeviceToken', timestamp: 123456789 }, | ||
}, | ||
{ device: 'aabbc788', status: '413', response: { reason: 'PayloadTooLarge' } }, | ||
{ device: 'fbcde238', error: { message: 'connection failed' } }, | ||
], | ||
`Unexpected result: ${JSON.stringify(response.failed)}` | ||
); | ||
}); | ||
}); | ||
}); | ||
}); | ||
describe('broadcast', async () => { | ||
describe('single notification behaviour', async () => { | ||
let provider; | ||
context('transmission succeeds', async () => { | ||
beforeEach(async () => { | ||
provider = new Provider({ address: 'testapi' }); | ||
fakes.client.write.onCall(0).returns(Promise.resolve({ bundleId: 'abcd1234' })); | ||
}); | ||
it('invokes the writer with correct `this`', async () => { | ||
await provider.broadcast(notificationDouble(), 'abcd1234'); | ||
expect(fakes.client.write).to.be.calledOn(fakes.client); | ||
}); | ||
it('writes the notification to the client once', async () => { | ||
await provider.broadcast(notificationDouble(), 'abcd1234'); | ||
const notification = notificationDouble(); | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
const bundleId = 'abcd1234'; | ||
expect(fakes.client.write).to.be.calledOnce; | ||
expect(fakes.client.write).to.be.calledWith( | ||
builtNotification, | ||
bundleId, | ||
'broadcasts', | ||
'post' | ||
); | ||
}); | ||
it('does not pass the array index to writer', async () => { | ||
await provider.broadcast(notificationDouble(), 'abcd1234'); | ||
expect(fakes.client.write.firstCall.args[4]).to.be.undefined; | ||
}); | ||
it('resolves with the bundleId in the sent array', async () => { | ||
const result = await provider.broadcast(notificationDouble(), 'abcd1234'); | ||
expect(result).to.deep.equal({ | ||
sent: [{ bundleId: 'abcd1234' }], | ||
failed: [], | ||
}); | ||
}); | ||
}); | ||
it('resolves with the device token, status code and response or error of the unsent notifications', function () { | ||
return promise.then(response => { | ||
expect(response.failed[3].error).to.be.an.instanceof(Error); | ||
response.failed[3].error = { message: response.failed[3].error.message }; | ||
expect(response.failed).to.deep.equal( | ||
[ | ||
{ device: 'adfe5969', status: '400', response: { reason: 'MissingTopic' } }, | ||
{ | ||
device: 'abcd1335', | ||
status: '410', | ||
response: { reason: 'BadDeviceToken', timestamp: 123456789 }, | ||
}, | ||
{ device: 'aabbc788', status: '413', response: { reason: 'PayloadTooLarge' } }, | ||
{ device: 'fbcde238', error: { message: 'connection failed' } }, | ||
], | ||
`Unexpected result: ${JSON.stringify(response.failed)}` | ||
); | ||
context('error occurs', async () => { | ||
it('resolves with the bundleId, status code and response in the failed array', async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
fakes.client.write.onCall(0).returns( | ||
Promise.resolve({ | ||
bundleId: 'abcd1234', | ||
status: '400', | ||
response: { reason: 'BadDeviceToken' }, | ||
}) | ||
); | ||
const result = await provider.broadcast(notificationDouble(), 'abcd1234'); | ||
expect(result).to.deep.equal({ | ||
sent: [], | ||
failed: [ | ||
{ bundleId: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }, | ||
], | ||
}); | ||
}); | ||
it('rejects with the bundleId, status code and response in the failed array', async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
fakes.client.write.onCall(0).returns( | ||
Promise.reject({ | ||
bundleId: 'abcd1234', | ||
status: '400', | ||
response: { reason: 'BadDeviceToken' }, | ||
}) | ||
); | ||
const result = await provider.broadcast(notificationDouble(), 'abcd1234'); | ||
expect(result).to.deep.equal({ | ||
sent: [], | ||
failed: [ | ||
{ bundleId: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }, | ||
], | ||
}); | ||
}); | ||
}); | ||
}); | ||
context('when multiple notifications are passed', async () => { | ||
beforeEach(async () => { | ||
fakes.resolutions = [ | ||
{ bundleId: 'test123', 'apns-channel-id': 'abcd1234' }, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'adfe5969', | ||
status: '400', | ||
response: { reason: 'MissingTopic' }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'abcd1335', | ||
status: '410', | ||
response: { reason: 'BadDeviceToken', timestamp: 123456789 }, | ||
}, | ||
{ bundleId: 'test123', 'apns-channel-id': 'bcfe4433' }, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'aabbc788', | ||
status: '413', | ||
response: { reason: 'PayloadTooLarge' }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'fbcde238', | ||
error: new Error('connection failed'), | ||
}, | ||
]; | ||
}); | ||
context('streams are always returned', async () => { | ||
let response; | ||
beforeEach(async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
for (let i = 0; i < fakes.resolutions.length; i++) { | ||
fakes.client.write.onCall(i).returns(Promise.resolve(fakes.resolutions[i])); | ||
} | ||
response = await provider.broadcast( | ||
fakes.resolutions.map(res => notificationDouble(res['apns-channel-id'])), | ||
'test123' | ||
); | ||
}); | ||
it('resolves with the sent notifications', async () => { | ||
expect(response.sent).to.deep.equal([ | ||
{ bundleId: 'test123', 'apns-channel-id': 'abcd1234' }, | ||
{ bundleId: 'test123', 'apns-channel-id': 'bcfe4433' }, | ||
]); | ||
}); | ||
it('resolves with the bundleId, status code and response or error of the unsent notifications', async () => { | ||
expect(response.failed[3].error).to.be.an.instanceof(Error); | ||
response.failed[3].error = { message: response.failed[3].error.message }; | ||
expect(response.failed).to.deep.equal( | ||
[ | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'adfe5969', | ||
status: '400', | ||
response: { reason: 'MissingTopic' }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'abcd1335', | ||
status: '410', | ||
response: { reason: 'BadDeviceToken', timestamp: 123456789 }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'aabbc788', | ||
status: '413', | ||
response: { reason: 'PayloadTooLarge' }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'fbcde238', | ||
error: { message: 'connection failed' }, | ||
}, | ||
], | ||
`Unexpected result: ${JSON.stringify(response.failed)}` | ||
); | ||
}); | ||
}); | ||
}); | ||
}); | ||
describe('manageChannels', async () => { | ||
describe('single notification behaviour', async () => { | ||
let provider; | ||
context('transmission succeeds', async () => { | ||
beforeEach(async () => { | ||
provider = new Provider({ address: 'testapi' }); | ||
fakes.client.write.onCall(0).returns(Promise.resolve({ bundleId: 'abcd1234' })); | ||
}); | ||
it('invokes the writer with correct `this`', async () => { | ||
await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); | ||
expect(fakes.client.write).to.be.calledOn(fakes.client); | ||
}); | ||
it('writes the notification to the client once using create', async () => { | ||
await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); | ||
const notification = notificationDouble(); | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
const bundleId = 'abcd1234'; | ||
expect(fakes.client.write).to.be.calledOnce; | ||
expect(fakes.client.write).to.be.calledWith( | ||
builtNotification, | ||
bundleId, | ||
'channels', | ||
'post' | ||
); | ||
}); | ||
it('writes the notification to the client once using read', async () => { | ||
await provider.manageChannels(notificationDouble(), 'abcd1234', 'read'); | ||
const notification = notificationDouble(); | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
const bundleId = 'abcd1234'; | ||
expect(fakes.client.write).to.be.calledOnce; | ||
expect(fakes.client.write).to.be.calledWith( | ||
builtNotification, | ||
bundleId, | ||
'channels', | ||
'get' | ||
); | ||
}); | ||
it('writes the notification to the client once using readAll', async () => { | ||
await provider.manageChannels(notificationDouble(), 'abcd1234', 'readAll'); | ||
const notification = notificationDouble(); | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
const bundleId = 'abcd1234'; | ||
expect(fakes.client.write).to.be.calledOnce; | ||
expect(fakes.client.write).to.be.calledWith( | ||
builtNotification, | ||
bundleId, | ||
'allChannels', | ||
'get' | ||
); | ||
}); | ||
it('writes the notification to the client once using delete', async () => { | ||
await provider.manageChannels(notificationDouble(), 'abcd1234', 'delete'); | ||
const notification = notificationDouble(); | ||
const builtNotification = { | ||
headers: notification.headers(), | ||
body: notification.compile(), | ||
}; | ||
const bundleId = 'abcd1234'; | ||
expect(fakes.client.write).to.be.calledOnce; | ||
expect(fakes.client.write).to.be.calledWith( | ||
builtNotification, | ||
bundleId, | ||
'channels', | ||
'delete' | ||
); | ||
}); | ||
it('does not pass the array index to writer', async () => { | ||
await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); | ||
expect(fakes.client.write.firstCall.args[5]).to.be.undefined; | ||
}); | ||
it('resolves with the bundleId in the sent array', async () => { | ||
const result = await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); | ||
expect(result).to.deep.equal({ | ||
sent: [{ bundleId: 'abcd1234' }], | ||
failed: [], | ||
}); | ||
}); | ||
}); | ||
context('error occurs', async () => { | ||
it('throws error when unknown action is passed', async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
let receivedError; | ||
try { | ||
await provider.manageChannels(notificationDouble(), 'abcd1234', 'hello'); | ||
} catch (e) { | ||
receivedError = e; | ||
} | ||
expect(receivedError).to.exist; | ||
expect(receivedError.bundleId).to.equal('abcd1234'); | ||
expect(receivedError.error.message.startsWith('the action "hello"')).to.equal(true); | ||
}); | ||
it('resolves with the bundleId, status code and response in the failed array', async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
fakes.client.write.onCall(0).returns( | ||
Promise.resolve({ | ||
bundleId: 'abcd1234', | ||
status: '400', | ||
response: { reason: 'BadDeviceToken' }, | ||
}) | ||
); | ||
const result = await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); | ||
expect(result).to.deep.equal({ | ||
sent: [], | ||
failed: [ | ||
{ bundleId: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }, | ||
], | ||
}); | ||
}); | ||
it('rejects with the bundleId, status code and response in the failed array', async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
fakes.client.write.onCall(0).returns( | ||
Promise.reject({ | ||
bundleId: 'abcd1234', | ||
status: '400', | ||
response: { reason: 'BadDeviceToken' }, | ||
}) | ||
); | ||
const result = await provider.manageChannels(notificationDouble(), 'abcd1234', 'create'); | ||
expect(result).to.deep.equal({ | ||
sent: [], | ||
failed: [ | ||
{ bundleId: 'abcd1234', status: '400', response: { reason: 'BadDeviceToken' } }, | ||
], | ||
}); | ||
}); | ||
}); | ||
}); | ||
context('when multiple notifications are passed', async () => { | ||
beforeEach(async () => { | ||
fakes.resolutions = [ | ||
{ bundleId: 'test123', 'apns-channel-id': 'abcd1234' }, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'adfe5969', | ||
status: '400', | ||
response: { reason: 'MissingTopic' }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'abcd1335', | ||
status: '410', | ||
response: { reason: 'BadDeviceToken', timestamp: 123456789 }, | ||
}, | ||
{ bundleId: 'test123', 'apns-channel-id': 'bcfe4433' }, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'aabbc788', | ||
status: '413', | ||
response: { reason: 'PayloadTooLarge' }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'fbcde238', | ||
error: new Error('connection failed'), | ||
}, | ||
]; | ||
}); | ||
context('streams are always returned', async () => { | ||
let response; | ||
beforeEach(async () => { | ||
const provider = new Provider({ address: 'testapi' }); | ||
for (let i = 0; i < fakes.resolutions.length; i++) { | ||
fakes.client.write.onCall(i).returns(Promise.resolve(fakes.resolutions[i])); | ||
} | ||
response = await provider.manageChannels( | ||
fakes.resolutions.map(res => notificationDouble(res['apns-channel-id'])), | ||
'test123', | ||
'create' | ||
); | ||
}); | ||
it('resolves with the sent notifications', async () => { | ||
expect(response.sent).to.deep.equal([ | ||
{ bundleId: 'test123', 'apns-channel-id': 'abcd1234' }, | ||
{ bundleId: 'test123', 'apns-channel-id': 'bcfe4433' }, | ||
]); | ||
}); | ||
it('resolves with the bundleId, status code and response or error of the unsent notifications', async () => { | ||
expect(response.failed[3].error).to.be.an.instanceof(Error); | ||
response.failed[3].error = { message: response.failed[3].error.message }; | ||
expect(response.failed).to.deep.equal( | ||
[ | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'adfe5969', | ||
status: '400', | ||
response: { reason: 'MissingTopic' }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'abcd1335', | ||
status: '410', | ||
response: { reason: 'BadDeviceToken', timestamp: 123456789 }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'aabbc788', | ||
status: '413', | ||
response: { reason: 'PayloadTooLarge' }, | ||
}, | ||
{ | ||
bundleId: 'test123', | ||
'apns-channel-id': 'fbcde238', | ||
error: { message: 'connection failed' }, | ||
}, | ||
], | ||
`Unexpected result: ${JSON.stringify(response.failed)}` | ||
); | ||
}); | ||
}); | ||
}); | ||
}); | ||
describe('shutdown', function () { | ||
it('invokes shutdown on the client', function () { | ||
it('invokes shutdown on the client', async () => { | ||
const callback = sinon.spy(); | ||
@@ -193,6 +631,8 @@ const provider = new Provider({}); | ||
function notificationDouble() { | ||
function notificationDouble(pushType = undefined) { | ||
return { | ||
headers: sinon.stub().returns({}), | ||
headers: sinon.stub().returns({ pushType: pushType }), | ||
payload: { aps: { badge: 1 } }, | ||
removeNonChannelRelatedProperties: sinon.stub(), | ||
addPushTypeToPayloadIfNeeded: sinon.stub(), | ||
compile: function () { | ||
@@ -199,0 +639,0 @@ return JSON.stringify(this.payload); |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
532802
93
9565
287
25