Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
759 changes: 430 additions & 329 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,17 @@
"watchos"
],
"dependencies": {
"fast-jwt": "^5.0.0",
"undici": "^7.0.0"
"fast-jwt": "^6.0.1",
"undici": "^7.9.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@tsconfig/node20": "^20.1.4",
"@types/node": "^22.10.0",
"chai": "^5.1.2",
"dotenv": "^16.4.5",
"typescript": "^5.7.2",
"vitest": "^2.1.6"
"@tsconfig/node20": "^20.1.5",
"@types/node": "^22.15.19",
"chai": "^5.2.0",
"dotenv": "^16.5.0",
"typescript": "^5.8.3",
"vitest": "^3.1.4"
},
"scripts": {
"clean": "rm -rf dist",
Expand Down
57 changes: 43 additions & 14 deletions src/apns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { EventEmitter } from "node:events"
import { type PrivateKey, createSigner } from "fast-jwt"
import { type Dispatcher, Pool } from "undici"
import { ApnsError, type ApnsResponseError, Errors } from "./errors.js"
import { undici_getClientHttp2Session, undici_getPoolClients } from "./internals.js"
import { type Notification, Priority } from "./notifications/notification.js"

// APNS version
Expand All @@ -13,10 +14,15 @@ const SIGNING_ALGORITHM = "ES256"
// Reset our signing token every 55 minutes as reccomended by Apple
const RESET_TOKEN_INTERVAL_MS = 55 * 60 * 1000

export enum Host {
production = "api.push.apple.com",
development = "api.sandbox.push.apple.com",
}
// Ping the server every 10 minutes as reccomended by Apple
const PING_INTERVAL_MS = 10 * 60 * 1000

export const Host = {
production: "api.push.apple.com",
development: "api.sandbox.push.apple.com",
} as const

export type Host = (typeof Host)[keyof typeof Host]

export interface SigningToken {
value: string
Expand All @@ -43,6 +49,7 @@ export class ApnsClient extends EventEmitter {
readonly client: Pool

private _token: SigningToken | null
private _pingInterval: NodeJS.Timeout | null

constructor(options: ApnsOptions) {
super()
Expand All @@ -59,7 +66,9 @@ export class ApnsClient extends EventEmitter {
maxConcurrentStreams: 100,
})
this._token = null
this._supressH2Warning()
this._pingInterval = this.keepAlive
? setInterval(() => this.ping(), PING_INTERVAL_MS).unref()
: null
}

sendMany(notifications: Notification[]) {
Expand Down Expand Up @@ -104,6 +113,35 @@ export class ApnsClient extends EventEmitter {
return this._handleServerResponse(res, notification)
}

async ping() {
const sessions = undici_getPoolClients(this.client)
.map(undici_getClientHttp2Session)
.filter((session) => session !== null)
.filter((session) => !session.destroyed && !session.connecting && !session.closed)
const promises = sessions.map((session) => {
return new Promise<void>((resolve, reject) => {
session.ping((err) => (err ? reject(err) : resolve()))
})
})
return Promise.allSettled(promises)
}

async close() {
if (this._pingInterval) {
clearInterval(this._pingInterval)
this._pingInterval = null
}
await this.client.close()
}

async destroy(err?: Error | null) {
if (this._pingInterval) {
clearInterval(this._pingInterval)
this._pingInterval = null
}
await this.client.destroy(err ?? null)
}

private async _handleServerResponse(res: Dispatcher.ResponseData, notification: Notification) {
if (res.statusCode === 200) {
return notification
Expand Down Expand Up @@ -157,13 +195,4 @@ export class ApnsClient extends EventEmitter {

return token
}

private _supressH2Warning() {
process.once("warning", (warning: Error & { code?: string }) => {
if (warning.code === "UNDICI-H2") {
return
}
process.emit("warning", warning)
})
}
}
70 changes: 36 additions & 34 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,41 @@
import type { Notification } from "./notifications/notification.js"

export enum Errors {
badCertificate = "BadCertificate",
badCertificateEnvironment = "BadCertificateEnvironment",
badCollapseId = "BadCollapseId",
badDeviceToken = "BadDeviceToken",
badExpirationDate = "BadExpirationDate",
badMessageId = "BadMessageId",
badPath = "BadPath",
badPriority = "BadPriority",
badTopic = "BadTopic",
deviceTokenNotForTopic = "DeviceTokenNotForTopic",
duplicateHeaders = "DuplicateHeaders",
error = "Error",
expiredProviderToken = "ExpiredProviderToken",
forbidden = "Forbidden",
idleTimeout = "IdleTimeout",
internalServerError = "InternalServerError",
invalidProviderToken = "InvalidProviderToken",
invalidPushType = "InvalidPushType",
invalidSigningKey = "InvalidSigningKey",
methodNotAllowed = "MethodNotAllowed",
missingDeviceToken = "MissingDeviceToken",
missingProviderToken = "MissingProviderToken",
missingTopic = "MissingTopic",
payloadEmpty = "PayloadEmpty",
payloadTooLarge = "PayloadTooLarge",
serviceUnavailable = "ServiceUnavailable",
shutdown = "Shutdown",
tooManyProviderTokenUpdates = "TooManyProviderTokenUpdates",
tooManyRequests = "TooManyRequests",
topicDisallowed = "TopicDisallowed",
unknownError = "UnknownError",
unregistered = "Unregistered",
}
export const Errors = {
badCertificate: "BadCertificate",
badCertificateEnvironment: "BadCertificateEnvironment",
badCollapseId: "BadCollapseId",
badDeviceToken: "BadDeviceToken",
badExpirationDate: "BadExpirationDate",
badMessageId: "BadMessageId",
badPath: "BadPath",
badPriority: "BadPriority",
badTopic: "BadTopic",
deviceTokenNotForTopic: "DeviceTokenNotForTopic",
duplicateHeaders: "DuplicateHeaders",
error: "Error",
expiredProviderToken: "ExpiredProviderToken",
forbidden: "Forbidden",
idleTimeout: "IdleTimeout",
internalServerError: "InternalServerError",
invalidProviderToken: "InvalidProviderToken",
invalidPushType: "InvalidPushType",
invalidSigningKey: "InvalidSigningKey",
methodNotAllowed: "MethodNotAllowed",
missingDeviceToken: "MissingDeviceToken",
missingProviderToken: "MissingProviderToken",
missingTopic: "MissingTopic",
payloadEmpty: "PayloadEmpty",
payloadTooLarge: "PayloadTooLarge",
serviceUnavailable: "ServiceUnavailable",
shutdown: "Shutdown",
tooManyProviderTokenUpdates: "TooManyProviderTokenUpdates",
tooManyRequests: "TooManyRequests",
topicDisallowed: "TopicDisallowed",
unknownError: "UnknownError",
unregistered: "Unregistered",
} as const

export type Error = (typeof Errors)[keyof typeof Errors]

export interface ApnsResponseError {
reason: string
Expand Down
23 changes: 23 additions & 0 deletions src/internals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Http2Session } from "node:http2"
import type { Client } from "undici"
import type { Pool } from "undici"

export function undici_getPoolClients(pool: Pool): Client[] {
const symbols = Object.getOwnPropertySymbols(pool)
const clientsSymbol = symbols.find((sym) => sym.description === "clients")
if (!clientsSymbol) {
return []
}
// biome-ignore lint/suspicious/noExplicitAny: necessary to access private property
return (pool as any)[clientsSymbol] ?? []
}

export function undici_getClientHttp2Session(client: Client): Http2Session | null {
const symbols = Object.getOwnPropertySymbols(client)
const http2SessionSymbol = symbols.find((sym) => sym.description === "http2Session")
if (!http2SessionSymbol) {
return null
}
// biome-ignore lint/suspicious/noExplicitAny: necessary to access private property
return (client as any)[http2SessionSymbol] ?? null
}
12 changes: 7 additions & 5 deletions src/notifications/constants/priority.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export enum Priority {
immediate = 10,
throttled = 5,
low = 1,
}
export const Priority = {
immediate: 10,
throttled: 5,
low: 1,
} as const

export type Priority = (typeof Priority)[keyof typeof Priority]
24 changes: 13 additions & 11 deletions src/notifications/constants/push-type.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
export enum PushType {
alert = "alert",
background = "background",
voip = "voip",
complication = "complication",
fileprovider = "fileprovider",
mdm = "mdm",
liveactivity = "liveactivity",
location = "location",
pushtotalk = "pushtotalk",
}
export const PushType = {
alert: "alert",
background: "background",
voip: "voip",
complication: "complication",
fileprovider: "fileprovider",
mdm: "mdm",
liveactivity: "liveactivity",
location: "location",
pushtotalk: "pushtotalk",
} as const

export type PushType = (typeof PushType)[keyof typeof PushType]