// Sends push notifications through APNs with token-based auth: an ES256 JWT signed by the .p8 key // from the Apple Developer portal, over HTTP/2. No dependencies. import { createPrivateKey, sign, type KeyObject } from "node:crypto"; import { connect, type ClientHttp2Session } from "node:http2"; export interface ApnsConfig { keyId: string; teamId: string; /** The .p8 key's PEM text. */ key: string; /** The watch app's bundle ID, which is its APNs topic. */ topic: string; } export interface ApnsNotification { deviceToken: string; environment: "sandbox" | "production"; payload: object; /** Replaces an earlier notification with the same id on the device. */ collapseId?: string; } /** Device tokens APNs reported as no longer valid. */ export type ApnsResult = { ok: true } | { ok: false; status: number; reason: string; tokenInvalid: boolean }; const HOSTS = { sandbox: "https://api.sandbox.push.apple.com", production: "https://api.push.apple.com", } as const; // Apple rejects tokens older than an hour and throttles ones refreshed more often than every 20 minutes. const JWT_LIFETIME_MS = 40 * 60_000; export class Apns { private readonly key: KeyObject; private jwt: { value: string; issuedAt: number } | null = null; private readonly sessions = new Map(); constructor(private readonly config: ApnsConfig) { this.key = createPrivateKey(config.key); } async send(notification: ApnsNotification): Promise { const body = JSON.stringify(notification.payload); const session = this.session(HOSTS[notification.environment]); return new Promise((resolve) => { const request = session.request({ ":method": "POST", ":path": `/3/device/${notification.deviceToken}`, authorization: `bearer ${this.token()}`, "apns-topic": this.config.topic, "apns-push-type": "alert", "apns-priority": "10", ...(notification.collapseId ? { "apns-collapse-id": notification.collapseId } : {}), "content-type": "application/json", }); let status = 0; let response = ""; request.on("response", (headers) => { status = Number(headers[":status"]); }); request.setEncoding("utf8"); request.on("data", (chunk: string) => (response += chunk)); request.on("end", () => { if (status === 200) return resolve({ ok: true }); const reason = safeReason(response); resolve({ ok: false, status, reason, tokenInvalid: status === 410 || reason === "BadDeviceToken" || reason === "DeviceTokenNotForTopic", }); }); request.on("error", (error) => resolve({ ok: false, status: 0, reason: error.message, tokenInvalid: false })); request.end(body); }); } close() { for (const session of this.sessions.values()) session.close(); this.sessions.clear(); } private session(origin: string): ClientHttp2Session { const existing = this.sessions.get(origin); if (existing && !existing.closed && !existing.destroyed) return existing; const session = connect(origin); session.on("error", () => this.sessions.delete(origin)); session.on("close", () => this.sessions.delete(origin)); this.sessions.set(origin, session); return session; } private token(): string { if (this.jwt && Date.now() - this.jwt.issuedAt < JWT_LIFETIME_MS) return this.jwt.value; const issuedAt = Date.now(); const header = base64url(JSON.stringify({ alg: "ES256", kid: this.config.keyId })); const claims = base64url(JSON.stringify({ iss: this.config.teamId, iat: Math.floor(issuedAt / 1000) })); const signature = sign("sha256", Buffer.from(`${header}.${claims}`), { key: this.key, dsaEncoding: "ieee-p1363" }); this.jwt = { value: `${header}.${claims}.${signature.toString("base64url")}`, issuedAt }; return this.jwt.value; } } const base64url = (text: string) => Buffer.from(text).toString("base64url"); function safeReason(body: string): string { try { return (JSON.parse(body) as { reason?: string }).reason ?? body; } catch { return body; } }