用Node推送App消息 - APNs(Apple推送通知服务)的简单实现

提醒

如果你只是需要一个第三方包来实现APN服务的话,推荐直接使用现有的项目 node-apn

提前工作

使用私钥生成签名

// typescript

import * as crypto from 'crypto'
import * as fs from 'fs'
// 下面的引用请自行替换
import { APNs, RunMode, runMode } from './secret'
import { AsyncRedis } from './redis'

class APNsService {
    private static APNsAuthToken = 'APNs:push:token'

    private static host = {
        sandbox: 'api.sandbox.push.apple.com',
        product: 'api.push.apple.com',
    }

    private static base64Encode (data: {[x:string]: any}) {
        return Buffer.from(JSON.stringify(data), 'utf-8').toString('base64')
    }

    private static fromBase64 (base64:string) {
        return base64
            .replace(/=/g, '')
            .replace(/\+/g, '-')
            .replace(/\//g, '_')
    }

    private static getPrivateKey ():Promise<string> {
        return new Promise((resolve, reject) => {
            fs.readFile('./APNs_Auth_Key.p8', 'utf8', (err, data) => {
                if (err) return reject(err)
                resolve(data)
            })
        })
    }

    private static async generateAuthToken () {
        const header = {
            alg: 'ES256',
            kid: APNs.APNsAuthKeyId,
        }
        const payload: APNsPayload = {
            iss: APNs.AppleDeveloperId,
            iat: Math.floor(Date.now() / 1000)
        }

        const privateKey = await this.getPrivateKey()
        // 虽然这里标示为"RSA-SHA"算法,但也能用于计算ECDSA类型的算法
        const signer = crypto.createSign('RSA-SHA256')
        const headerPayload = `${this.base64Encode(header)}.${this.base64Encode(payload)}`
        let sig = (
            signer.update(headerPayload),
            signer.sign(privateKey, 'base64')
        )
        sig = this.fromBase64(sig)
        return `${headerPayload}.${sig}`
    }

    private static tokenInvalid (token: string): boolean {
        if (!token) return true
        const _token = token.split('.')
        if (_token.length !== 3) return true
        try {
            const payload: APNsPayload = JSON.parse(Buffer.from(_token[1], 'base64').toString('utf-8'))
            // 计算token的有效时间,JWT签名后的有效期仅为1小时,这里提前半小时刷新
            // 注意不要频繁刷新token,否则请求会被拒绝
            const now = Math.floor(Date.now() / 1000)
            if (now > payload.iat + 1800) return true
        } catch { return true }
        return false
    }

    static async getAuthToken () {
        // 使用Redis缓存token,可自行替换其他数据库
        let token = await AsyncRedis.get(this.APNsAuthToken)
        if (this.tokenInvalid(token)) {
            token = await this.generateAuthToken()
            await AsyncRedis.set(this.APNsAuthToken, token)
        }
        return token
    }
}

使用Http/2请求

在生成签名后,作者尝试向苹果服务器发出请求,结果遇到了报错

 Parse Error: Expected HTTP/
    at Socket.socketOnData (_http_client.js:456:22)
    at Socket.emit (events.js:210:5)
    at addChunk (_stream_readable.js:308:12)
    at readableAddChunk (_stream_readable.js:289:11)
    at Socket.Readable.push (_stream_readable.js:223:10)
    at TCP.onStreamRead (internal/stream_base_commons.js:182:23)

作者一度怀疑是node版本的问题(甚至还升级了版本 XD)。然而待作者仔细阅读Apple的文档后发现,原来是APNs只支持http/2的协议,难怪一直跑不通呢!这里也非常佩服苹果,总是投入资源来推动新技术的普及。

不过问题找到了,其实也会很容易解决

// typescript
import * as http from 'http'
import * as https from 'https'
import * as http2 from 'http2'
import * as querystring from 'querystring'
// 下面的引用请自行替换(不关键)
import { logger } from '../base/log4j'
import { Failed } from '../errors/api-error'
import { Errors } from '../error-result-types'

export class API {
    private protocol = 'https'
    private debug = false

    private host!: string
    private path = '/'
    private headers = {
        'accept-encoding': 'gzip, deflate, br',
        'content-type': 'application/json; charset=utf-8',
        'user-agent':
            'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.75 Safari/537.36',
    }

    private options: {
        host: string,
        path: string,
        headers: {[x:string]: string},
        method: 'GET' | 'POST' | 'DELETE' | 'HEAD' | 'PUT',
        port?: number
    } = {
        host: this.host,
        path: this.path,
        headers: this.headers,
        method: 'GET',
    }

    private payload = {}
    private body = {}

    private response?: http.IncomingMessage

    private constructor (args: {
        path: string,
        host?: string,
        port?: number
        debug?: boolean,
    }) {
        this.options.path = this.path = args.path ? args.path : '/'
        this.options.host = this.host = args.host ? args.host : 'api.evink.cn'
        this.debug = args.debug ? args.debug : false
        if (args.port) this.options.port = args.port
    }

    addHeader (headers: { [x: string]: string }): API {
        this.options.headers = Object.assign(this.headers, headers)
        return this
    }

    addPayload (payload: { [x: string]: any }): API {
        this.payload = Object.assign(this.payload, payload)
        return this
    }

    addBody (body: { [x: string]: any }): API {
        this.body = Object.assign(this.body, body)
        return this
    }

    private http2Request<T> (callback?: ((data: T) => void)): Promise<T> {
        return new Promise((resolve) => {
            let data = ''
            try {
                const client = http2.connect(`${this.protocol}://${this.host}`)
                let path = this.path
                if (Object.keys(this.payload).length > 0) {
                    path = `${this.path}?${querystring.stringify(this.payload)}`
                }
                const options = {
                    ':method': this.options.method,
                    ':path': path,
                    ...this.headers
                }
                const request = client.request(options)
                if (this.debug) {
                    logger.debug('Request options:', this.host, options)
                    logger.debug('Request payload:', this.payload)
                    logger.debug('Request body:', this.body)
                }
                let jsonTypeResponse = false
                request.on('response', (headers, flags) => {
                    if (':status' in headers) logger.info('Response status code: ', headers[':status'])
                    if ('content-type' in headers && headers['content-type']?.includes('application/json'))
                        jsonTypeResponse = true
                })
                request.on('data', chunk => {
                    data += chunk
                    if (this.debug) logger.debug('Response chunk:', chunk)
                })
                request.on('end', () => {
                    client.close()
                    if (jsonTypeResponse) data = JSON.parse(data)
                    resolve(data as any)
                    if (callback) callback(data as any)
                    if (this.debug) logger.debug('Response data:', data)
                })
                request.end(JSON.stringify(this.body))
            }
            catch (err) {
                logger.error(err)
                throw new Failed(Errors.InternalError)
            }
        })

    }

    static get (args: {
        path: string,
        host?: string,
        port?: number
        debug?: boolean,
    }): API {
        const api = new API(args)
        api.options.method = 'GET'
        return api
    }

    static post (args: {
        path: string,
        host?: string,
        port?: number
        debug?: boolean,
    }): API {
        const api = new API(args)
        api.options.method = 'POST'
        return api
    }

    send (callback?: (data: string | { [x: string]: any }) => void): Promise<string | { [x: string]: any }> {
        return this.http2Request(callback)
    }
}

开始推送

//typescript
import { APNsService } from './apn-service'
import { API } from './request'
import { APNs, RunMode, runMode } from './secret'

export class AppPush {

    static async iosPush (args: { deviceToken: string, alert: PushAlert }): Promise<void> {
        const { deviceToken, alert } = { ...args }
        const host = runMode === RunMode.dev? APNsService.host.sandbox : APNsService.host.product
        await API.post({
            host, path: `/3/device/${deviceToken}`,http2: true,
            debug: true
        }).addHeader({
            authorization: `bearer ${await APNsService.getAuthToken()}`,
            'apns-push-type': 'alert', // 'alert' | 'background'
            'apns-topic': APNs.BundleId,
        }).addBody({
            aps: {alert},
        }).send()
    }
}

AppPush.iosPush({
    deviceToken: 'your-device-token',
    alert: {
        title: '推送测试',
        subtile: '',
        body: '这是详细的推送消息',
    }
})

请求成功后,Apple会返回对应的状态码和reason。一般出现403状态都说明生成的签名错误,请检查你是否使用错了证书(本文中的签名方法仅针对 *.p8格式的私钥)

参考:Apple官方文档

APN服务对应的状态码:

status code reason 描述
200 / 成功
400 BadDeviceToken DeviceToken无效,或者环境错了。
400 BadTopic apns-topic值无效
400 DeviceTokenNotForTopic DeviceToken和Topic不匹配
400 MissingDeviceToken 没有DeviceToken
400 MissingTopic 请求头缺少apns-topic
403 BadCertificate 证书无效
403 BadCertificateEnvironment 证书的环境不对
403 ExpiredProviderToken Token过期了
403 MissingProviderToken 没有提供客户端认证证书,也没有设置authorization
413 PayloadTooLarge payload太大。(不得大于4 KB)
429 TooManyProviderTokenUpdates Token更新太过频繁(小于20分钟)
429 TooManyRequests 同时向同一台设备发送了过多请求