Gateway: watch API, pairing, notifications, APNs sender, CLI, tests

This commit is contained in:
gib committed 2026-11-01 02:09:18 -05:00
1 parent 972f6e4be5
commit 6ca098b701
12 files changed
+1297 -1

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
// 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 app's bundle ID. */
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;
}
}