expo-server-sdk
Version:
Server-side library for working with Expo using Node.js
277 lines (276 loc) • 11 kB
JavaScript
/**
* 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 {
}