apns2
Version:
Node client for connecting to Apple's Push Notification Service using the new HTTP/2 protocol with JSON web tokens.
127 lines (126 loc) • 4.41 kB
JavaScript
import { EventEmitter } from "node:events";
import { createSigner } from "fast-jwt";
import { Pool } from "undici";
import { ApnsError, Errors } from "./errors.js";
import { Priority } from "./notifications/notification.js";
// APNS version
const API_VERSION = 3;
// Signing algorithm for JSON web token
const SIGNING_ALGORITHM = "ES256";
// Reset our signing token every 55 minutes as reccomended by Apple
const RESET_TOKEN_INTERVAL_MS = 55 * 60 * 1000;
export var Host;
(function (Host) {
Host["production"] = "api.push.apple.com";
Host["development"] = "api.sandbox.push.apple.com";
})(Host || (Host = {}));
export class ApnsClient extends EventEmitter {
team;
keyId;
host;
signingKey;
defaultTopic;
keepAlive;
client;
_token;
constructor(options) {
super();
this.team = options.team;
this.keyId = options.keyId;
this.signingKey = options.signingKey;
this.defaultTopic = options.defaultTopic;
this.host = options.host ?? Host.production;
this.keepAlive = options.keepAlive ?? true;
this.client = new Pool(`https://${this.host}:443`, {
connections: this.keepAlive ? 32 : 1,
pipelining: this.keepAlive ? 1 : 0,
allowH2: true,
maxConcurrentStreams: 100,
});
this._token = null;
this._supressH2Warning();
}
sendMany(notifications) {
const promises = notifications.map((notification) => this.send(notification).catch((error) => ({ error })));
return Promise.all(promises);
}
async send(notification) {
const headers = {
authorization: `bearer ${this._getSigningToken()}`,
"apns-push-type": notification.pushType,
"apns-topic": notification.options.topic ?? this.defaultTopic,
};
if (notification.priority !== Priority.immediate) {
headers["apns-priority"] = notification.priority.toString();
}
const expiration = notification.options.expiration;
if (typeof expiration !== "undefined") {
headers["apns-expiration"] =
typeof expiration === "number"
? expiration.toFixed(0)
: (expiration.getTime() / 1000).toFixed(0);
}
if (notification.options.collapseId) {
headers["apns-collapse-id"] = notification.options.collapseId;
}
const res = await this.client.request({
path: `/${API_VERSION}/device/${encodeURIComponent(notification.deviceToken)}`,
method: "POST",
headers: headers,
body: JSON.stringify(notification.buildApnsOptions()),
idempotent: true,
blocking: false,
});
return this._handleServerResponse(res, notification);
}
async _handleServerResponse(res, notification) {
if (res.statusCode === 200) {
return notification;
}
const responseError = await res.body.json().catch(() => ({
reason: Errors.unknownError,
timestamp: Date.now(),
}));
const error = new ApnsError({
statusCode: res.statusCode,
notification: notification,
response: responseError,
});
// Reset signing token if expired
if (error.reason === Errors.expiredProviderToken) {
this._token = null;
}
// Emit specific and generic errors
this.emit(error.reason, error);
this.emit(Errors.error, error);
throw error;
}
_getSigningToken() {
if (this._token && Date.now() - this._token.timestamp < RESET_TOKEN_INTERVAL_MS) {
return this._token.value;
}
const claims = {
iss: this.team,
iat: Math.floor(Date.now() / 1000),
};
const signer = createSigner({
key: this.signingKey,
algorithm: SIGNING_ALGORITHM,
kid: this.keyId,
});
const token = signer(claims);
this._token = {
value: token,
timestamp: Date.now(),
};
return token;
}
_supressH2Warning() {
process.once("warning", (warning) => {
if (warning.code === "UNDICI-H2") {
return;
}
process.emit("warning", warning);
});
}
}