/// /** * iOS 18 broadcast channels for Live Activities. * * Channels let many devices subscribe to a single broadcast push — ideal for * "everyone watching this game" style updates. There are two endpoints: * * - **Channel management** (`api-manage-broadcast(.sandbox).push.apple.com`, * ports `2196`/`2195`): create / read / delete / list channels. Creating a * channel returns a base64 `apns-channel-id`. * - **Broadcast send** (the regular push host `api.push.apple.com:443`): * `POST /4/broadcasts/apps/` with an `apns-channel-id` header and * **no** `apns-topic`. Carries the same `update`/`end` envelope (you cannot * `start` an activity via broadcast). * * @packageDocumentation */ import { Http2Client } from './apns'; import { JwtProvider } from './jwt'; import { buildEndPayload, buildPushHeaders, buildUpdatePayload, parseApnsResult, serializePayload, type ApnsPayload, } from './payload'; import { ApnsError, type ApnsPriority, type ApnsResult, type EndOptions, type LiveActivityPusherConfig, type UpdateOptions, } from './types'; /** Default APNs priority for broadcast pushes. */ const DEFAULT_PRIORITY: ApnsPriority = 10; /** Resolve the channel-management host from config. */ function resolveManageHost(config: LiveActivityPusherConfig): string { return config.production ? 'api-manage-broadcast.push.apple.com' : 'api-manage-broadcast.sandbox.push.apple.com'; } /** Resolve the broadcast-send host (same as the device push host). */ function resolveSendHost(config: LiveActivityPusherConfig): string { if (config.host) return config.host; return config.production ? 'api.push.apple.com' : 'api.sandbox.push.apple.com'; } /** Result of {@link BroadcastChannelManager.createChannel}. */ export interface CreateChannelResult { /** Base64 channel id assigned by APNs (the `apns-channel-id` header). */ channelId: string; /** Raw parsed APNs result (status, apns-id, etc.). */ raw: ApnsResult; } /** Result of {@link BroadcastChannelManager.listChannels}. */ export interface ListChannelsResult { /** Base64 channel ids returned by APNs. */ channels: string[]; /** Raw parsed APNs result. */ raw: ApnsResult; } /** * Manage broadcast channels and send broadcast pushes (iOS 18+). * * Create one per app/environment and share it; it keeps two reused HTTP/2 * sessions warm (management + send). Call {@link BroadcastChannelManager.close} * on shutdown. */ export interface BroadcastChannelManager { /** * Create a new broadcast channel. Returns the base64 `apns-channel-id` to * store and hand to clients (as `input-push-channel`) and to * {@link broadcast}. Up to 10,000 channels per app/environment. */ createChannel(options?: { /** `message-storage-policy` (`0` no storage, `1` store; default `1`). */ messageStoragePolicy?: 0 | 1; }): Promise; /** Read a channel's metadata; resolves to the parsed APNs result. */ readChannel(channelId: string): Promise; /** Delete a channel. Resolves to the parsed APNs result (`204` on success). */ deleteChannel(channelId: string): Promise; /** List all channel ids for the app. */ listChannels(): Promise; /** Broadcast a content-state `update` to all subscribers of a channel. */ broadcastUpdate( channelId: string, options: UpdateOptions ): Promise; /** Broadcast an `end` to all subscribers of a channel. */ broadcastEnd(channelId: string, options?: EndOptions): Promise; /** * Broadcast a pre-built `update`/`end` payload to a channel. Escape hatch; * enforces the 4 KB limit and standard headers. */ broadcast( channelId: string, payload: ApnsPayload, options?: { priority?: ApnsPriority; expiration?: number; apnsId?: string } ): Promise; /** Gracefully close both underlying HTTP/2 sessions. Idempotent. */ close(): Promise; } /** * Construct a {@link BroadcastChannelManager}. * * @throws {ApnsError} `'invalid-argument'` when required credentials are missing. */ export function createBroadcastChannelManager( config: LiveActivityPusherConfig ): BroadcastChannelManager { for (const field of ['key', 'keyId', 'teamId', 'bundleId'] as const) { if (typeof config[field] !== 'string' || config[field]!.length === 0) { throw new ApnsError( 'invalid-argument', `createBroadcastChannelManager: \`${field}\` is required and must be a non-empty string.` ); } } const bundleId = config.bundleId; const jwt = new JwtProvider({ key: config.key, keyId: config.keyId, teamId: config.teamId, }); // Two long-lived sessions: management (port 2196/2195) and send (push host). const manageClient = new Http2Client({ host: resolveManageHost(config), port: config.production ? 2196 : 2195, connectTimeoutMs: config.connectTimeoutMs, requestTimeoutMs: config.requestTimeoutMs, }); const sendClient = new Http2Client({ host: resolveSendHost(config), port: config.port ?? 443, connectTimeoutMs: config.connectTimeoutMs, requestTimeoutMs: config.requestTimeoutMs, }); /** Authorization header value with the (cached) provider token. */ function authHeader(): string { return `bearer ${jwt.getToken()}`; } /** Broadcast a serialized payload to `/4/broadcasts/apps/`. */ async function broadcast( channelId: string, payload: ApnsPayload, priority: ApnsPriority, expiration?: number, apnsId?: string ): Promise { assertChannelId(channelId); const { json, byteLength } = serializePayload(payload); const headers = buildPushHeaders({ token: '', // unused: channel-based, no /3/device path topic: '', // broadcasts MUST NOT send apns-topic priority, byteLength, apnsId, expiration, channelId, }); headers['authorization'] = authHeader(); const response = await sendClient.request({ method: 'POST', path: `/4/broadcasts/apps/${bundleId}`, headers, body: json, }); return parseApnsResult(response); } return { async createChannel(options = {}): Promise { const body = JSON.stringify({ 'message-storage-policy': options.messageStoragePolicy ?? 1, 'push-type': 'LiveActivity', }); const response = await manageClient.request({ method: 'POST', path: `/1/apps/${bundleId}/channels`, headers: { authorization: authHeader(), 'content-type': 'application/json', 'content-length': Buffer.byteLength(body, 'utf8'), }, body, }); const raw = parseApnsResult(response); if (!raw.success || !raw.channelId) { throw ApnsError.fromResult(raw); } return { channelId: raw.channelId, raw }; }, async readChannel(channelId): Promise { assertChannelId(channelId); const response = await manageClient.request({ method: 'GET', path: `/1/apps/${bundleId}/channels`, headers: { authorization: authHeader(), 'apns-channel-id': channelId, }, }); return parseApnsResult(response); }, async deleteChannel(channelId): Promise { assertChannelId(channelId); const response = await manageClient.request({ method: 'DELETE', path: `/1/apps/${bundleId}/channels`, headers: { authorization: authHeader(), 'apns-channel-id': channelId, }, }); return parseApnsResult(response); }, async listChannels(): Promise { const response = await manageClient.request({ method: 'GET', path: `/1/apps/${bundleId}/all-channels`, headers: { authorization: authHeader() }, }); const raw = parseApnsResult(response); let channels: string[] = []; if (response.body) { try { const parsed = JSON.parse(response.body) as { channels?: unknown }; if (Array.isArray(parsed.channels)) { channels = parsed.channels.filter( (c): c is string => typeof c === 'string' ); } } catch { // Leave channels empty on a non-JSON body. } } return { channels, raw }; }, async broadcastUpdate(channelId, options): Promise { const payload = buildUpdatePayload({ state: options.state, alert: options.alert, staleDate: options.staleDate, relevanceScore: options.relevanceScore, dismissalDate: options.dismissalDate, }); return broadcast( channelId, payload, options.priority ?? DEFAULT_PRIORITY, options.expiration, options.apnsId ); }, async broadcastEnd(channelId, options = {}): Promise { const payload = buildEndPayload({ state: options.state, dismissalDate: options.dismissalDate, }); return broadcast( channelId, payload, options.priority ?? DEFAULT_PRIORITY, options.expiration, options.apnsId ); }, async broadcast(channelId, payload, options = {}): Promise { return broadcast( channelId, payload, options.priority ?? DEFAULT_PRIORITY, options.expiration, options.apnsId ); }, async close(): Promise { await Promise.all([manageClient.close(), sendClient.close()]); }, }; } /** Throw if a channel id is missing/empty. */ function assertChannelId(channelId: string): void { if (typeof channelId !== 'string' || channelId.length === 0) { throw new ApnsError( 'invalid-argument', 'A non-empty base64 `apns-channel-id` is required.' ); } }