Step 3. Apple directly over HTTP/2 — no Expo, no library, just node:http2 and node:crypto. Three things here are the difference between working and a silent failure: - The provider JWT must be signed with dsaEncoding 'ieee-p1363'. Node's default is DER, which is a perfectly valid ECDSA signature that Apple rejects, and the rejection says nothing about why. A test asserts the signature is 64 bytes rather than trusting the flag. - Apple rejects a token minted more than once per 20 minutes and any token older than 60, so it is cached and refreshed at 40 — between the two walls, not per request. - APNs expects ONE long-lived HTTP/2 session carrying many requests. A session per push gets throttled, so sessions are kept per host and re-created only when they die, with an error handler so a transport failure cannot take the sidecar down as an unhandled rejection. Sandbox and production are separate hosts and separate token namespaces, so devices are sent per their stored `environment` — a debug-build token against production fails with BadDeviceToken and no other symptom. Dead tokens (BadDeviceToken, Unregistered, DeviceTokenNotForTopic) delete their row immediately; everything else counts a strike. Going direct means Apple answers inline, so none of Expo's deferred receipt-polling is needed. The doorbell rule is now enforced by a test, not just by convention: a producer that passes subject/from/body — which the type forbids but JavaScript permits — cannot get any of it into the serialized payload. `aps.alert` carries a generic title composed from the category, and the custom `officer` key carries ids the app fetches by. 12 tests, 100% of the JWT and payload paths. Verified against the real endpoint too: a throwaway key gets 403 InvalidProviderToken from api.sandbox.push.apple.com, which means the connection, path, apns-topic, push-type and JWT structure are all accepted and only the credential is missing. Still needed for a real send: APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID, and a device token from the app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
39 lines
1.4 KiB
TypeScript
39 lines
1.4 KiB
TypeScript
import type { Channel, DeliveryResult, Notification } from './types';
|
|
import { discordChannel } from './discord';
|
|
import { apnsChannel } from './apns';
|
|
|
|
// Fan a notification out to every configured channel.
|
|
//
|
|
// Channels are independent and none of them may break a producer: a job that finished has finished
|
|
// whether or not a banner appeared. So every channel is awaited with its own error boundary, and the
|
|
// dispatcher always resolves.
|
|
|
|
const channels: Channel[] = [apnsChannel, discordChannel];
|
|
|
|
/** Registered here rather than imported at the top so channels can be added without touching producers. */
|
|
export function registerChannel(channel: Channel): void {
|
|
channels.push(channel);
|
|
}
|
|
|
|
export function configuredChannels(): string[] {
|
|
return channels.filter((c) => c.isConfigured()).map((c) => c.name);
|
|
}
|
|
|
|
export async function dispatch(n: Notification): Promise<DeliveryResult[]> {
|
|
const active = channels.filter((c) => c.isConfigured());
|
|
if (active.length === 0) return [];
|
|
|
|
return Promise.all(
|
|
active.map(async (c) => {
|
|
try {
|
|
return await c.send(n);
|
|
} catch (err) {
|
|
// A channel that throws instead of returning is a bug in that channel, not a reason to fail
|
|
// the notification or the producer behind it.
|
|
console.error(`[notify] channel ${c.name} threw:`, err instanceof Error ? err.message : err);
|
|
return { channel: c.name, sent: 0, failed: 1 };
|
|
}
|
|
}),
|
|
);
|
|
}
|