UNPKG

expo-server-sdk

Version:

Server-side library for working with Expo using Node.js

277 lines (276 loc) 11 kB
/** * expo-server-sdk * * Use this if you are running Node on your server backend when you are working with Expo * Application Services * https://expo.dev */ import assert from 'node:assert'; import { createRequire } from 'node:module'; import { gzipSync } from 'node:zlib'; import promiseLimit from 'promise-limit'; import promiseRetry from 'promise-retry'; import { fetch } from 'undici'; import { defaultConcurrentRequestLimit, getReceiptsApiUrl, pushNotificationChunkLimit, pushNotificationReceiptChunkLimit, requestRetryMinTimeout, sendApiUrl, } from "./ExpoClientValues.js"; const require = createRequire(import.meta.url); export class Expo { static pushNotificationChunkSizeLimit = pushNotificationChunkLimit; static pushNotificationReceiptChunkSizeLimit = pushNotificationReceiptChunkLimit; httpAgent; limitConcurrentRequests; accessToken; retryMinTimeout; constructor(options = {}) { this.httpAgent = options.httpAgent; this.limitConcurrentRequests = promiseLimit(options.maxConcurrentRequests ?? defaultConcurrentRequestLimit); this.retryMinTimeout = options.retryMinTimeout ?? requestRetryMinTimeout; this.accessToken = options.accessToken; } /** * Returns `true` if the token is an Expo push token */ static isExpoPushToken(token) { return (typeof token === 'string' && (((token.startsWith('ExponentPushToken[') || token.startsWith('ExpoPushToken[')) && token.endsWith(']')) || /^[a-z\d]{8}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{4}-[a-z\d]{12}$/i.test(token))); } /** * Sends the given messages to their recipients via push notifications and returns an array of * push tickets. Each ticket corresponds to the message at its respective index (the nth receipt * is for the nth message) and contains a receipt ID. Later, after Expo attempts to deliver the * messages to the underlying push notification services, the receipts with those IDs will be * available for a period of time (approximately a day). * * There is a limit on the number of push notifications you can send at once. Use * `chunkPushNotifications` to divide an array of push notification messages into appropriately * sized chunks. */ async sendPushNotificationsAsync(messages) { const url = new URL(sendApiUrl); const actualMessagesCount = Expo._getActualMessageCount(messages); const data = await this.limitConcurrentRequests(async () => { return await promiseRetry(async (retry) => { try { return await this.requestAsync(url.toString(), { httpMethod: 'post', body: messages, shouldCompress(body) { return body.length > 1024; }, }); } catch (e) { // if Expo servers rate limit, retry with exponential backoff if (e.statusCode === 429) { return retry(e); } throw e; } }, { retries: 2, factor: 2, minTimeout: this.retryMinTimeout, }); }); if (!Array.isArray(data) || data.length !== actualMessagesCount) { const apiError = new Error(`Expected Expo to respond with ${actualMessagesCount} ${actualMessagesCount === 1 ? 'ticket' : 'tickets'} but got ${data.length}`); apiError['data'] = data; throw apiError; } return data; } async getPushNotificationReceiptsAsync(receiptIds) { const data = await this.requestAsync(getReceiptsApiUrl, { httpMethod: 'post', body: { ids: receiptIds }, shouldCompress(body) { return body.length > 1024; }, }); if (!data || typeof data !== 'object' || Array.isArray(data)) { const apiError = new Error(`Expected Expo to respond with a map from receipt IDs to receipts but received data of another type`); apiError['data'] = data; throw apiError; } return data; } chunkPushNotifications(messages) { const chunks = []; let chunk = []; let chunkMessagesCount = 0; for (const message of messages) { if (Array.isArray(message.to)) { let partialTo = []; for (const recipient of message.to) { partialTo.push(recipient); chunkMessagesCount++; if (chunkMessagesCount >= pushNotificationChunkLimit) { // Cap this chunk here if it already exceeds PUSH_NOTIFICATION_CHUNK_LIMIT. // Then create a new chunk to continue on the remaining recipients for this message. chunk.push({ ...message, to: partialTo }); chunks.push(chunk); chunk = []; chunkMessagesCount = 0; partialTo = []; } } if (partialTo.length) { // Add remaining `partialTo` to the chunk. chunk.push({ ...message, to: partialTo }); } } else { chunk.push(message); chunkMessagesCount++; } if (chunkMessagesCount >= pushNotificationChunkLimit) { // Cap this chunk if it exceeds PUSH_NOTIFICATION_CHUNK_LIMIT. // Then create a new chunk to continue on the remaining messages. chunks.push(chunk); chunk = []; chunkMessagesCount = 0; } } if (chunkMessagesCount) { // Add the remaining chunk to the chunks. chunks.push(chunk); } return chunks; } chunkPushNotificationReceiptIds(receiptIds) { return this.chunkItems(receiptIds, pushNotificationReceiptChunkLimit); } chunkItems(items, chunkSize) { const chunks = []; let chunk = []; for (const item of items) { chunk.push(item); if (chunk.length >= chunkSize) { chunks.push(chunk); chunk = []; } } if (chunk.length) { chunks.push(chunk); } return chunks; } async requestAsync(url, options) { const json = JSON.stringify(options.body); assert(json != null, `JSON request body must not be null`); // NOTE: This can be replaced with an import with the `{ type: "json" }` // attribute when we drop support for node versions below v20.10.0, when // import attributes were stabilized const sdkVersion = require('../package.json').version; const requestHeaders = new Headers({ Accept: 'application/json', 'Accept-Encoding': 'gzip, deflate', 'User-Agent': `expo-server-sdk-node/${sdkVersion}`, 'Content-Type': 'application/json', }); if (this.accessToken) { requestHeaders.set('Authorization', `Bearer ${this.accessToken}`); } const fetchOptions = { method: options.httpMethod, headers: requestHeaders, }; if (options.shouldCompress(json)) { fetchOptions.body = gzipSync(Buffer.from(json)); requestHeaders.set('Content-Encoding', 'gzip'); } else { fetchOptions.body = json; } if (this.httpAgent) { fetchOptions.dispatcher = this.httpAgent; } const response = await fetch(url, fetchOptions); if (response.status !== 200) { const apiError = await this.parseErrorResponseAsync(response); throw apiError; } const textBody = await response.text(); // We expect the API response body to be JSON let result; try { result = JSON.parse(textBody); } catch { const apiError = await this.getTextResponseErrorAsync(response, textBody); throw apiError; } if (result.errors) { const apiError = this.getErrorFromResult(response, result); throw apiError; } return result.data; } async parseErrorResponseAsync(response) { const textBody = await response.text(); let result; try { result = JSON.parse(textBody); } catch { return await this.getTextResponseErrorAsync(response, textBody); } if (!result.errors || !Array.isArray(result.errors) || !result.errors.length) { const apiError = await this.getTextResponseErrorAsync(response, textBody); apiError['errorData'] = result; return apiError; } return this.getErrorFromResult(response, result); } async getTextResponseErrorAsync(response, text) { const apiError = new Error(`Expo responded with an error with status code ${response.status}: ` + text); apiError['statusCode'] = response.status; apiError['errorText'] = text; return apiError; } /** * Returns an error for the first API error in the result, with an optional `others` field that * contains any other errors. */ getErrorFromResult(response, result) { const noErrorsMessage = `Expected at least one error from Expo`; assert(result.errors, noErrorsMessage); const [errorData, ...otherErrorData] = result.errors; assert.ok(errorData, noErrorsMessage); const error = this.getErrorFromResultError(errorData); if (otherErrorData.length) { error['others'] = otherErrorData.map((data) => this.getErrorFromResultError(data)); } error['statusCode'] = response.status; return error; } /** * Returns an error for a single API error */ getErrorFromResultError(errorData) { const error = new Error(errorData.message); error['code'] = errorData.code; if (errorData.details != null) { error['details'] = errorData.details; } if (errorData.stack != null) { error['serverStack'] = errorData.stack; } return error; } static _getActualMessageCount(messages) { return messages.reduce((total, message) => { if (Array.isArray(message.to)) { total += message.to.length; } else { total++; } return total; }, 0); } } export default Expo; class ExtensibleError extends Error { }