115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
// 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<string, ClientHttp2Session>();
|
|
|
|
constructor(private readonly config: ApnsConfig) {
|
|
this.key = createPrivateKey(config.key);
|
|
}
|
|
|
|
async send(notification: ApnsNotification): Promise<ApnsResult> {
|
|
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;
|
|
}
|
|
}
|