///
/**
* HTTP/2 transport to APNs with a single, reused {@link ClientHttp2Session}.
*
* APNs strongly prefers long-lived HTTP/2 connections multiplexing many
* requests. This module lazily opens one session per `(host, port)`, keeps it
* warm, and transparently re-opens it after a `close`/`goaway`/error so callers
* never deal with reconnection. It is intentionally generic over the
* `:path`/headers so the same session powers device pushes, broadcasts, and
* channel management.
*
* @packageDocumentation
*/
import * as http2 from 'node:http2';
import { ApnsError } from './types';
const {
HTTP2_HEADER_METHOD,
HTTP2_HEADER_PATH,
HTTP2_HEADER_STATUS,
HTTP2_HEADER_AUTHORITY,
} = http2.constants;
/** A single APNs request to dispatch over the session. */
export interface Http2RequestSpec {
/** HTTP method (`POST`, `GET`, `DELETE`, …). */
method: string;
/** Request `:path`, e.g. `/3/device/`. */
path: string;
/** Application headers (authorization, apns-*). The pseudo-headers are added. */
headers: http2.OutgoingHttpHeaders;
/** Optional UTF-8 request body. */
body?: string;
}
/** The raw HTTP/2 response, before APNs-specific parsing. */
export interface Http2Response {
/** The `:status` pseudo-header. */
status: number;
/** All response headers (lowercased keys). */
headers: http2.IncomingHttpHeaders;
/** The response body decoded as UTF-8 (often empty for 2xx). */
body: string;
}
/** Construction options for {@link Http2Client}. */
export interface Http2ClientOptions {
/** Fully-qualified host, e.g. `api.push.apple.com`. */
host: string;
/** Port (`443` / `2197` for push, `2196`/`2195` for channel management). */
port: number;
/** Connect timeout in ms (default `10_000`). */
connectTimeoutMs?: number;
/** Per-request timeout in ms (default `10_000`). */
requestTimeoutMs?: number;
}
/**
* Reused, auto-reconnecting HTTP/2 client for one APNs endpoint.
*
* Not exported from the package surface directly — {@link createLiveActivityPusher}
* and {@link createBroadcastChannelManager} own instances of it.
*/
export class Http2Client {
private readonly authority: string;
private readonly connectTimeoutMs: number;
private readonly requestTimeoutMs: number;
/** The live session, or `undefined` when disconnected. */
private session?: http2.ClientHttp2Session;
/** In-flight connect promise, so concurrent callers share one handshake. */
private connecting?: Promise;
/** Set once {@link close} is called; further requests are refused. */
private closed = false;
constructor(options: Http2ClientOptions) {
this.authority = `https://${options.host}:${options.port}`;
this.connectTimeoutMs = options.connectTimeoutMs ?? 10_000;
this.requestTimeoutMs = options.requestTimeoutMs ?? 10_000;
}
/** Lazily connect (or reuse) the session, sharing a concurrent handshake. */
private connect(): Promise {
if (this.closed) {
return Promise.reject(
new ApnsError('transport', 'HTTP/2 client has been closed.')
);
}
const existing = this.session;
if (existing !== undefined && !existing.closed && !existing.destroyed) {
return Promise.resolve(existing);
}
if (this.connecting !== undefined) return this.connecting;
this.connecting = new Promise((resolve, reject) => {
const session = http2.connect(this.authority, {
// APNs requires ALPN h2; node negotiates it for https authorities.
settings: { enablePush: false },
});
const onConnectError = (err: Error): void => {
cleanup();
session.destroy();
if (this.session === session) this.session = undefined;
reject(
new ApnsError('transport', `Failed to connect to APNs: ${err.message}`, {
cause: err,
})
);
};
const timer = setTimeout(() => {
onConnectError(new Error(`connect timed out after ${this.connectTimeoutMs}ms`));
}, this.connectTimeoutMs);
// Don't keep the event loop alive solely for the connect timer.
if (typeof timer.unref === 'function') timer.unref();
const cleanup = (): void => {
clearTimeout(timer);
session.removeListener('error', onConnectError);
};
session.once('error', onConnectError);
session.once('connect', () => {
cleanup();
this.session = session;
this.connecting = undefined;
// After a successful connect, attach long-lived listeners so a dropped
// session is forgotten and the next request transparently reconnects.
const forget = (): void => {
if (this.session === session) this.session = undefined;
};
session.on('error', forget);
session.once('close', forget);
session.once('goaway', () => {
// Graceful shutdown from APNs: stop using this session for new streams.
forget();
if (!session.destroyed) session.close();
});
resolve(session);
});
}).catch((err) => {
this.connecting = undefined;
throw err;
});
return this.connecting;
}
/**
* Send one request over the (lazily connected) session and resolve the parsed
* status/headers/body. Rejects with an {@link ApnsError} of kind `'transport'`
* on connection/stream failures or timeouts.
*/
async request(spec: Http2RequestSpec): Promise {
const session = await this.connect();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn: () => void): void => {
if (settled) return;
settled = true;
fn();
};
const requestHeaders: http2.OutgoingHttpHeaders = {
...spec.headers,
[HTTP2_HEADER_METHOD]: spec.method,
[HTTP2_HEADER_PATH]: spec.path,
[HTTP2_HEADER_AUTHORITY]: this.authority.replace(/^https:\/\//, ''),
};
let stream: http2.ClientHttp2Stream;
try {
stream = session.request(requestHeaders);
} catch (err) {
finish(() =>
reject(
new ApnsError('transport', `Failed to open HTTP/2 stream: ${(err as Error).message}`, {
cause: err,
})
)
);
return;
}
stream.setEncoding('utf8');
stream.setTimeout(this.requestTimeoutMs, () => {
finish(() => {
stream.close(http2.constants.NGHTTP2_CANCEL);
reject(
new ApnsError(
'transport',
`APNs request timed out after ${this.requestTimeoutMs}ms.`
)
);
});
});
let status = 0;
let responseHeaders: http2.IncomingHttpHeaders = {};
let body = '';
stream.once('response', (headers) => {
responseHeaders = headers;
const raw = headers[HTTP2_HEADER_STATUS];
status = typeof raw === 'number' ? raw : Number(raw) || 0;
});
stream.on('data', (chunk: string) => {
body += chunk;
});
stream.once('end', () => {
finish(() => resolve({ status, headers: responseHeaders, body }));
});
stream.once('error', (err) => {
finish(() =>
reject(
new ApnsError('transport', `HTTP/2 stream error: ${err.message}`, {
cause: err,
})
)
);
});
if (spec.body !== undefined) stream.end(spec.body);
else stream.end();
});
}
/**
* Gracefully close the session and refuse further requests. Safe to call more
* than once. In-flight streams are allowed to complete before the socket
* closes (HTTP/2 graceful close semantics).
*/
close(): Promise {
this.closed = true;
const session = this.session;
this.session = undefined;
if (session === undefined || session.closed || session.destroyed) {
return Promise.resolve();
}
return new Promise((resolve) => {
session.close(() => resolve());
});
}
}