Как делать11 октября 2026 г.6 мин чтения
React Native + ExpoШаг 8 из 9

Push-уведомления: как устроены и как подключить

Коротко

Push-уведомление отправляет ваш сервер, а доставляет на телефон служба платформы: Firebase Cloud Messaging на Android и Apple Push Notification service на iOS. Приложение получает токен — адрес устройства для этой службы — и передаёт его вашему серверу; в Expo это ExpoPushToken, а сервер отправляет сообщения через Expo Push Service, который сам говорит с FCM и APNs. В SDK 57 push не работают в Expo Go: нужна development build, учётные данные FCM и платный аккаунт Apple Developer для iOS.

РуководствоУровень: СреднийPush-уведомленияПрактика: ~2–4 ч

После материала вы сможете

  • объяснить цепочку «сервер → Expo Push Service → FCM/APNs → устройство» и чем ExpoPushToken отличается от нативного токена
  • получить ExpoPushToken в development build с правильным порядком «канал → разрешение → токен» и сохранить его на сервере
  • отправить уведомление через expo-server-sdk, обработать тикеты и квитанции и удалять токены с DeviceNotRegistered
  • открыть нужный экран по нажатию на уведомление, проверяя путь по белому списку

Проверено на

Expo SDK 57 (expo 57.0.27)React Native 0.86.3expo-notifications 57.0.22expo-constants 57.0.21expo-server-sdk 7.2.0TypeScript 6.0.3 / 7.0.2jest-expo 57.0.5 + Jest 29.7undici 7.30.0Node.js 24 LTS (24.18.0)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Как устроены push: сервер, служба платформы и токен
  2. 02Ограничения и подготовка проекта
  3. 03Разрешение и токен в приложении
  4. 04Хранение токенов на сервере и их устаревание
  5. 05Отправка: Expo Push API и expo-server-sdk
  6. 06Нажатие на уведомление и переход на экран
  7. 07Проверка: что проверено без устройства

Как устроены push: сервер, служба платформы и токен

Телефон не держит соединение с каждым сервером, который хочет ему что-то сообщить. Вместо этого система поддерживает одно соединение со службой платформы: на Android это Firebase Cloud Messaging (FCM), на iOS — Apple Push Notification service (APNs). Чтобы служба знала, кому доставить сообщение, приложение регистрируется и получает токен — по формулировке Apple, адрес приложения на конкретном устройстве. Токен уходит на ваш сервер, и сервер указывает его в каждом сообщении.

  1. 01Ваш сервер
  2. 02Expo Push Service
  3. 03FCM / APNs
  4. 04Телефон
Сервер знает только токены; доставку до устройства выполняет служба платформы.

Expo добавляет промежуточное звено: getExpoPushTokenAsync() выдаёт ExpoPushToken вида ExponentPushToken[…], а сервер отправляет сообщения в Expo Push API одним форматом для обеих платформ. Expo Push Service кладёт их в очередь и передаёт в FCM и APNs с вашими учётными данными. Это не обязательный путь: getDevicePushTokenAsync() возвращает нативный токен, с которым можно работать с FCM и APNs напрямую.

Токен

Expo Push Service
ExpoPushToken, один формат
Напрямую FCM и APNs
Нативный токен FCM и токен APNs

Сервер

Expo Push Service
Один HTTPS-запрос или expo-server-sdk
Напрямую FCM и APNs
Две интеграции: FCM HTTP v1 и APNs

Возможности

Expo Push Service
Основные поля: заголовок, текст, данные, канал, бейдж, звук
Напрямую FCM и APNs
Все функции платформ

Стоимость и лимиты

Expo Push Service
Бесплатно; 600 уведомлений в секунду на проект
Напрямую FCM и APNs
По правилам Google и Apple

Уведомления бывают двух видов. Обычное (с title и body) система показывает сама, если приложение не на экране. «Безголовое» фоновое уведомление несёт только данные и будит JavaScript-задачу — но доставку в приложение ОС не гарантирует, а Apple советует слать такие не чаще двух-трёх в час. Expo рекомендует обычные уведомления, если фоновая обработка не нужна.

Ограничения и подготовка проекта

  • Expo Go с SDK 53 не поддерживает push: нужна development build — своя сборка приложения с нативным модулем уведомлений.
  • Проверять можно на физических устройствах, на эмуляторе Android с Google Play services и на симуляторе iOS из Xcode 14+ (macOS 13+, iOS 16+).
  • Для Android нужны учётные данные FCM v1 из проекта Firebase, загруженные в EAS.
  • Для iOS нужен платный аккаунт Apple Developer: при первой сборке EAS CLI предложит создать ключ APNs.
  • Изменения в нативной конфигурации (плагин, канал по умолчанию, иконка) требуют новой сборки — по воздуху они не обновляются.
bash
npx expo install expo-notifications expo-constants
npm install --global eas-cli
eas init    # создать или привязать проект EAS
eas build   # development build с учётными данными push; профили — в eas.json
Пакеты, проект EAS и development build
app.json
{
  "expo": {
    "name": "Orders",
    "scheme": "orders",
    "plugins": [
      [
        "expo-notifications",
        {
          "color": "#1F6FEB",
          "defaultChannel": "orders"
        }
      ]
    ]
  }
}
Фрагмент app.json: config plugin и канал по умолчанию для FCM

Пример состоит из двух частей: приложение Expo (пути файлов — от корня проекта) и сервер на Node.js в отдельной папке server/.

Разрешение и токен в приложении

Как и с камерой, спрашивать разрешение лучше в момент, когда пользователь понимает пользу: кнопка «Сообщать о статусе заказа» после оформления работает лучше, чем диалог при первом запуске. iOS не требует текста-обоснования для уведомлений. На Android 13 и новее разрешение POST_NOTIFICATIONS запрашивается во время работы, и системный диалог не появится, пока приложение не создало хотя бы один канал уведомлений — поэтому канал создаётся первым.

src/push/registerForPush.ts
import { Platform } from 'react-native';
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';

export type PushRegistration =
  | { status: 'granted'; token: string }
  | { status: 'denied' } // пользователь отказал — работаем без push
  | { status: 'error'; reason: string }; // нет сети, нет projectId — повторим позже

export async function registerForPush(): Promise<PushRegistration> {
  // Android 13+: системный запрос не появится, пока в приложении нет ни одного канала
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('orders', {
      name: 'Статус заказов',
      importance: Notifications.AndroidImportance.HIGH,
    });
  }

  const current = await Notifications.getPermissionsAsync();
  const permission = current.granted ? current : await Notifications.requestPermissionsAsync();
  if (!permission.granted) return { status: 'denied' };

  const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
  if (!projectId) return { status: 'error', reason: 'no-project-id' };

  try {
    // запрос идёт на серверы Expo — без сети он отклоняется
    const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
    return { status: 'granted', token: data };
  } catch (e) {
    return { status: 'error', reason: String(e) };
  }
}

export async function sendTokenToServer(apiUrl: string, authToken: string, pushToken: string) {
  const response = await fetch(`${apiUrl}/v1/push-tokens`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}` },
    body: JSON.stringify({ token: pushToken, platform: Platform.OS }),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
}
Канал → разрешение → projectId → ExpoPushToken; сетевую ошибку возвращаем как статус
App.tsx
import './src/push/handler';
import { useCallback, useState } from 'react';
import { StatusBar } from 'expo-status-bar';
import { Button, StyleSheet, Text, View } from 'react-native';
import { registerForPush, sendTokenToServer, type PushRegistration } from './src/push/registerForPush';
import { useNotificationNavigation } from './src/push/useNotificationNavigation';

const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.example.com';

export default function App() {
  const [screen, setScreen] = useState('/orders');
  const [push, setPush] = useState<PushRegistration | null>(null);

  // в проекте с Expo Router здесь будет router.push(url)
  const open = useCallback((url: string) => setScreen(url), []);
  useNotificationNavigation(open);

  async function enablePush() {
    const result = await registerForPush();
    setPush(result);
    if (result.status === 'granted') {
      // токен авторизации берётся из SecureStore после входа; здесь — заглушка
      await sendTokenToServer(API_URL, 'YOUR_AUTH_TOKEN', result.token).catch(() => undefined);
    }
  }

  return (
    <View style={styles.container}>
      <Text>Экран: {screen}</Text>
      {push?.status !== 'granted' && (
        <Button title="Сообщать о статусе заказа" onPress={enablePush} />
      )}
      {push?.status === 'denied' && (
        <Text>Уведомления выключены. Статус заказа всегда виден в разделе «Мои заказы».</Text>
      )}
      <StatusBar style="auto" />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, paddingTop: 64, gap: 12, backgroundColor: '#fff' },
});
Включение push по кнопке и отправка токена на сервер (токен авторизации — заглушка)

getExpoPushTokenAsync() ходит на серверы Expo, поэтому без сети отклоняется: документация советует перехватить ошибку и повторить позже. Регистрацию стоит выполнять при каждом запуске уже разрешившего уведомления пользователя — Apple прямо предупреждает, что токен нельзя кэшировать навсегда: он меняется при восстановлении из резервной копии, установке на новое устройство и переустановке ОС.

Хранение токенов на сервере и их устаревание

На сервере токен — это запись «пользователь — устройство». У одного человека может быть несколько устройств, а одно устройство после смены аккаунта должно перейти к новому владельцу. В учебном примере хранилище в памяти, но интерфейс тот же, что у таблицы в базе.

server/src/store.ts
// Хранилище push-токенов. В учебном примере — в памяти; в проекте — таблица
// push_tokens (user_id, token, platform, updated_at) в вашей базе.
export interface TokenStore {
  save(userId: string, token: string): Promise<void>;
  listForUser(userId: string): Promise<string[]>;
  remove(token: string): Promise<void>;
}

export class MemoryTokenStore implements TokenStore {
  private byUser = new Map<string, Set<string>>();

  async save(userId: string, token: string) {
    for (const tokens of this.byUser.values()) tokens.delete(token); // токен принадлежит одному пользователю
    const tokens = this.byUser.get(userId) ?? new Set<string>();
    tokens.add(token);
    this.byUser.set(userId, tokens);
  }

  async listForUser(userId: string) {
    return [...(this.byUser.get(userId) ?? [])];
  }

  async remove(token: string) {
    for (const tokens of this.byUser.values()) tokens.delete(token);
  }
}
Интерфейс хранилища и реализация в памяти

Вход и каждый запуск с разрешением

Что делать с токеном
Сохранить или обновить: ExpoPushToken не меняется при обновлении приложения, но на Android может смениться после переустановки

Выход из аккаунта

Что делать с токеном
Удалить запись на сервере, чтобы уведомления прошлого пользователя не приходили новому

Тикет или квитанция с DeviceNotRegistered

Что делать с токеном
Удалить токен и не слать на него, пока устройство не зарегистрируется заново

Смена applicationId или проекта Expo

Что делать с токеном
Токены станут другими — старые перестанут работать

Отправка: Expo Push API и expo-server-sdk

Expo Push API — это HTTPS POST на https://exp.host/--/api/v2/push/send с JSON-сообщением или массивом до 100 сообщений. Запрос ниже — формат из документации с плейсхолдером вместо токена; настоящий токен берётся с устройства.

bash
curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/send" -d '{
  "to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
  "title": "Заказ готов",
  "body": "Заказ №1042 можно забрать",
  "data": { "url": "/orders/1042" },
  "channelId": "orders"
}'
Формат запроса к Expo Push API (плейсхолдер токена, в уроке не отправлялся)

Ответ — не доставка, а тикет: status: "ok" и id означают только, что Expo принял сообщение. Через некоторое время по id можно получить квитанцию (push receipt): приняли ли сообщение FCM или APNs. Expo советует проверять квитанции примерно через 15 минут, хранятся они 24 часа. Даже ok в квитанции не гарантирует, что выключенный телефон получил уведомление.

server/src/push.ts
import { Expo, type ExpoPushMessage } from 'expo-server-sdk';
import type { TokenStore } from './store.ts';

export type PushContent = { title: string; body: string; url: string };
export type PendingReceipt = { receiptId: string; token: string };

export async function notifyUser(
  expo: Expo,
  store: TokenStore,
  userId: string,
  content: PushContent,
): Promise<PendingReceipt[]> {
  const tokens = (await store.listForUser(userId)).filter((t) => Expo.isExpoPushToken(t));
  const messages: ExpoPushMessage[] = tokens.map((to) => ({
    to,
    title: content.title,
    body: content.body,
    data: { url: content.url }, // куда открыть приложение по нажатию
    sound: 'default',
    channelId: 'orders', // канал должен быть создан в приложении
  }));

  const pending: PendingReceipt[] = [];
  // не больше 100 сообщений в запросе — SDK режет на части сам
  for (const chunk of expo.chunkPushNotifications(messages)) {
    const tickets = await expo.sendPushNotificationsAsync(chunk);
    for (const [i, ticket] of tickets.entries()) {
      const token = chunk[i].to as string;
      if (ticket.status === 'ok') pending.push({ receiptId: ticket.id, token });
      else if (ticket.details?.error === 'DeviceNotRegistered') await store.remove(token);
      else console.error('push ticket error', ticket.message);
    }
  }
  return pending; // квитанции проверяем позже — Expo советует через 15 минут
}

export async function checkReceipts(expo: Expo, store: TokenStore, pending: PendingReceipt[]) {
  const tokenById = new Map(pending.map((p) => [p.receiptId, p.token]));
  let failed = 0;
  for (const ids of expo.chunkPushNotificationReceiptIds([...tokenById.keys()])) {
    const receipts = await expo.getPushNotificationReceiptsAsync(ids);
    for (const [id, receipt] of Object.entries(receipts)) {
      if (receipt.status === 'ok') continue;
      failed++;
      // устройство больше не принимает push: перестаём слать на этот токен
      if (receipt.details?.error === 'DeviceNotRegistered') await store.remove(tokenById.get(id)!);
    }
  }
  return { checked: tokenById.size, failed };
}
Отправка пользователю и проверка квитанций через expo-server-sdk
server/src/server.ts
import { createServer, type IncomingMessage } from 'node:http';
import { Expo } from 'expo-server-sdk';
import { notifyUser } from './push.ts';
import type { TokenStore } from './store.ts';

async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
  let raw = '';
  for await (const chunk of req) raw += chunk;
  return raw ? JSON.parse(raw) : {};
}

// Учебная авторизация: в проекте userId берут из проверенной сессии или JWT, а не из заголовка
function userIdOf(req: IncomingMessage): string | null {
  const id = req.headers['x-demo-user'];
  return typeof id === 'string' && id ? id : null;
}

export function createPushServer(expo: Expo, store: TokenStore) {
  return createServer(async (req, res) => {
    const send = (status: number, body?: unknown) => {
      if (body === undefined) return void res.writeHead(status).end();
      res.writeHead(status, { 'content-type': 'application/json' });
      res.end(JSON.stringify(body));
    };
    const userId = userIdOf(req);
    if (!userId) return send(401, { error: 'unauthorized' });

    try {
      if (req.method === 'POST' && req.url === '/v1/push-tokens') {
        const { token } = await readJson(req);
        if (!Expo.isExpoPushToken(token)) return send(400, { error: 'invalid_token' });
        await store.save(userId, token);
        return send(204);
      }
      if (req.method === 'POST' && req.url === '/v1/test-push') {
        const pending = await notifyUser(expo, store, userId, {
          title: 'Заказ готов',
          body: 'Заказ №1042 можно забрать',
          url: '/orders/1042',
        });
        return send(202, { queued: pending.length });
      }
      send(404, { error: 'not_found' });
    } catch (error) {
      console.error(error);
      send(500, { error: 'internal' });
    }
  });
}
HTTP-сервер на node:http: регистрация токена и тестовая отправка; авторизация упрощена
  • expo-server-sdk от команды Expo режет сообщения на пачки, ограничивает число одновременных соединений, сжимает большие запросы и повторяет их с паузами при 429 и 5xx — Expo рекомендует его вместо ручных запросов.
  • Общий размер уведомления — не больше 4096 байт; при превышении придёт ошибка MessageTooBig. В data кладут идентификатор и путь, а не весь заказ.
  • Если токены утекут, кто угодно сможет отправить от вашего имени сообщение вашим пользователям. В панели EAS на expo.dev можно включить расширенную защиту push (enhanced push security): тогда запросы без access token отклоняются, а SDK передаёт его через опцию accessToken.
  • channelId должен совпадать с каналом, созданным в приложении: на Android уведомление в несуществующий канал не показывается.
server/src/main.ts
import { Expo } from 'expo-server-sdk';
import { createPushServer } from './server.ts';
import { MemoryTokenStore } from './store.ts';

// EXPO_ACCESS_TOKEN нужен, если в настройках проекта включена защита push (Enhanced Security)
const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
const port = Number(process.env.PORT ?? 3000);

createPushServer(expo, new MemoryTokenStore()).listen(port, () => {
  console.log(`push server on http://localhost:${port}`);
});
Запуск: node src/main.ts (Node.js 24 выполняет TypeScript без сборки)

Нажатие на уведомление и переход на экран

В SDK 57 уведомление, пришедшее при открытом приложении, по умолчанию не показывается: поведение задают через setNotificationHandler, и он должен ответить за 3 секунды. В SDK 58 это меняется — уведомления на переднем плане будут показываться по умолчанию, поэтому явный обработчик полезен в обоих случаях.

src/push/handler.ts
import * as Notifications from 'expo-notifications';

// В SDK 57 уведомление при открытом приложении по умолчанию не показывается —
// решаем явно. Вызывается один раз, на верхнем уровне модуля.
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: false,
    shouldSetBadge: false,
  }),
});
Показывать баннер, даже когда приложение открыто

Переход по нажатию складывается из двух случаев. Если приложение было закрыто и запущено нажатием, ответ лежит в getLastNotificationResponse(). Если работало — срабатывает addNotificationResponseReceivedListener. Путь берём из data.url, но не открываем что попало: данные могли устареть или быть подменены, поэтому допускаем только экраны из белого списка.

src/push/notificationUrl.ts
// Путь из data.url уведомления — только внутренние экраны из белого списка.
// Push может прийти с устаревшими или подменёнными данными: не открываем что попало.
const ALLOWED = [/^\/orders\/[\w-]+$/, /^\/orders$/, /^\/profile$/];

export function notificationUrl(data: unknown): string | null {
  if (typeof data !== 'object' || data === null) return null;
  const url = (data as { url?: unknown }).url;
  if (typeof url !== 'string') return null;
  return ALLOWED.some((re) => re.test(url)) ? url : null;
}
Белый список внутренних путей
src/push/useNotificationNavigation.ts
import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { notificationUrl } from './notificationUrl';

export function useNotificationNavigation(open: (url: string) => void) {
  useEffect(() => {
    function handle(response: Notifications.NotificationResponse) {
      const url = notificationUrl(response.notification.request.content.data);
      if (url) open(url);
    }

    // приложение было закрыто и запущено нажатием на уведомление
    const last = Notifications.getLastNotificationResponse();
    if (last) handle(last);

    // приложение уже работало (на экране или в фоне)
    const subscription = Notifications.addNotificationResponseReceivedListener(handle);
    return () => subscription.remove();
  }, [open]);
}
Холодный старт и нажатие при работающем приложении; в проекте с Expo Router open — это router.push

Проверка: что проверено без устройства

Реальную доставку можно увидеть только на устройстве с development build: Expo предлагает для этого веб-инструмент push notifications tool, куда вставляют токен с телефона. Всё остальное проверяется тестами. Сервер тестируется встроенным node:test, а Expo Push API подменяется MockAgent из undici: SDK принимает его через опцию httpAgent, и ни один запрос не уходит на настоящий exp.host.

server/test/push.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import { MockAgent } from 'undici';
import { Expo } from 'expo-server-sdk';
import { checkReceipts } from '../src/push.ts';
import { createPushServer } from '../src/server.ts';
import { MemoryTokenStore } from '../src/store.ts';

// плейсхолдеры в формате Expo — настоящих токенов и устройств нет
const TOKEN_A = 'ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]';
const TOKEN_B = 'ExponentPushToken[bbbbbbbbbbbbbbbbbbbbbb]';

let agent: MockAgent;
let store: MemoryTokenStore;
let expo: Expo;

beforeEach(() => {
  agent = new MockAgent();
  agent.disableNetConnect(); // ни одного запроса к настоящему exp.host
  store = new MemoryTokenStore();
  expo = new Expo({ httpAgent: agent });
});

async function withServer(fn: (url: string) => Promise<void>) {
  const server = createPushServer(expo, store).listen(0);
  await new Promise((r) => server.once('listening', r));
  try {
    await fn(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
  } finally {
    server.close();
  }
}

test('регистрация токена: 401 без пользователя, 400 на мусор, 204 на токен Expo', async () => {
  await withServer(async (url) => {
    const post = (body: unknown, user?: string) =>
      fetch(`${url}/v1/push-tokens`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', ...(user ? { 'x-demo-user': user } : {}) },
        body: JSON.stringify(body),
      });
    assert.equal((await post({ token: TOKEN_A })).status, 401);
    assert.equal((await post({ token: 'not-a-token' }, 'u1')).status, 400);
    assert.equal((await post({ token: TOKEN_A }, 'u1')).status, 204);
    assert.deepEqual(await store.listForUser('u1'), [TOKEN_A]);
  });
});

test('отправка: тикет DeviceNotRegistered удаляет токен, ok попадает в квитанции', async () => {
  await store.save('u1', TOKEN_A);
  await store.save('u1', TOKEN_B);
  let sentBody: Array<Record<string, unknown>> = [];
  agent
    .get('https://exp.host')
    .intercept({ path: '/--/api/v2/push/send', method: 'POST' })
    .reply(200, (opts) => {
      sentBody = JSON.parse(String(opts.body)); // SDK сжимает gzip только большие запросы
      return {
        data: [
          { status: 'ok', id: 'receipt-1' },
          { status: 'error', message: 'not registered', details: { error: 'DeviceNotRegistered' } },
        ],
      };
    });

  await withServer(async (url) => {
    const res = await fetch(`${url}/v1/test-push`, { method: 'POST', headers: { 'x-demo-user': 'u1' } });
    assert.equal(res.status, 202);
    assert.deepEqual(await res.json(), { queued: 1 });
  });
  assert.equal(sentBody.length, 2);
  assert.deepEqual(sentBody[0].data, { url: '/orders/1042' });
  assert.equal(sentBody[0].channelId, 'orders');
  assert.deepEqual(await store.listForUser('u1'), [TOKEN_A]);
});

test('квитанции: DeviceNotRegistered удаляет токен', async () => {
  await store.save('u1', TOKEN_A);
  agent
    .get('https://exp.host')
    .intercept({ path: '/--/api/v2/push/getReceipts', method: 'POST' })
    .reply(200, { data: { 'receipt-1': { status: 'error', details: { error: 'DeviceNotRegistered' } } } });

  const result = await checkReceipts(expo, store, [{ receiptId: 'receipt-1', token: TOKEN_A }]);
  assert.deepEqual(result, { checked: 1, failed: 1 });
  assert.deepEqual(await store.listForUser('u1'), []);
});
Тесты сервера: регистрация, отправка с DeviceNotRegistered, квитанции

В приложении jest-expo с моками expo-notifications и expo-constants проверяет порядок «канал → разрешение → токен», отказ, ошибку сети и навигацию по нажатию, в том числе отказ открывать путь не из белого списка.

terminal
$ cd server && npx tsc --noEmit && npm test
✔ регистрация токена: 401 без пользователя, 400 на мусор, 204 на токен Expo (30.1206ms)
✔ отправка: тикет DeviceNotRegistered удаляет токен, ok попадает в квитанции (14.2506ms)
✔ квитанции: DeviceNotRegistered удаляет токен (1.4411ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0
$ cd ../app && npx tsc --noEmit && npx jest
PASS __tests__/notificationNavigation.test.tsx
PASS __tests__/registerForPush.test.ts
Tests:       6 passed, 6 total
$ npx expo export --platform android --platform ios
Android Bundled 6092ms index.ts (643 modules)
iOS Bundled 9340ms index.ts (645 modules)
Exported: dist
Временный проект: сервер (3 теста), приложение (6 тестов), сборка бандлов Android и iOS
  • Development build установлена, push работает не в Expo Go
  • Учётные данные FCM v1 и ключ APNs загружены в EAS
  • Канал Android создан до запроса токена, channelId в сообщениях совпадает
  • Токен сохраняется на сервере при входе и удаляется при выходе
  • DeviceNotRegistered в тикетах и квитанциях удаляет токен
  • Нажатие открывает только разрешённые экраны
Как проверено2 · пройдено3 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    server/src/{store,push,server,main}.ts

    временный Node-проект (ESM, Node.js 24.18.0 с выполнением TypeScript без сборки): tsc --noEmit (TypeScript 7.0.2); node --test — 3 теста с undici 7.30.0 MockAgent вместо exp.host (disableNetConnect), expo-server-sdk 7.2.0; ручной запуск node src/main.ts: POST /v1/push-tokens → 204, мусорный токен → 400

  • запущено, работает

    app/src/push/{registerForPush,notificationUrl,useNotificationNavigation,handler}.ts, App.tsx, app.json

    create-expo-app (blank-typescript), Expo SDK 57.0.27, expo-notifications 57.0.22; tsc --noEmit; jest-expo 57.0.5 — 6 тестов с моками expo-notifications/expo-constants (порядок канал→разрешение→токен, отказ, ошибка сети, холодный старт, белый список); npx expo export --platform android --platform ios; expo-doctor 21/21; npx expo config --type introspect — aps-environment и канал FCM по умолчанию

  • не запускалось

    curl к Expo Push API (плейсхолдер токена)

    формат из документации Expo; запрос не отправлялся — реальных токенов нет

  • не запускалось

    eas init / eas build, учётные данные FCM v1 и APNs

    нет аккаунтов Firebase/Apple Developer для учебного проекта; шаги — по документации Expo

  • не запускалось

    получение токена и реальная доставка push на устройство

    устройства и development build недоступны; поведение — по документации Expo, Apple и Firebase

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.