Expo Updates Protocol v1
Otapush говорит на Expo Updates Protocol v1 — том же сетевом протоколе, который уже реализован в стандартной клиентской библиотеке expo-updates. Никакого Otapush SDK, никакого форка, никакого своего нативного модуля: вы указываете updates.url на сервер Otapush, и дальше всё делает клиент, который пришёл вместе с вашим Expo SDK.
Эта страница описывает протокол таким, каким его реализует Otapush, — включая места, недоописанные в спецификации, и обходные пути, которые обнаруживаются только на реальных устройствах. Всё, что ниже, сверено с исходниками сервера и с expo-updates 0.26.19; там, где Otapush отступает от спецификации или чего-то не реализует, об этом сказано прямо.
Нормативный источник — Expo Updates v1. Он короткий и сознательно оставляет часть вопросов открытыми.
Что обязан вернуть соответствующий спецификации сервер
Соответствующий сервер выставляет ровно одну точку, которую опрашивает клиент, плюс сколько угодно URL для скачивания ассетов. У Otapush их две:
GET /api/updates/:slug/manifest
GET /api/updates/:slug/assets?asset=<key>&runtimeVersion=<version>
:slug — слаг приложения из портала, то самое значение, которое otapush init записывает в app.json. Обе точки публичные: ни сессии портала, ни API-ключа. API-ключ защищает публикацию, а не раздачу.
На запрос манифеста сервер обязан ответить ровно одним из трёх:
- Манифестом — есть обновление, которое клиенту следует скачать и запустить.
- Директивой — обновления нет, но клиенту всё равно нужна инструкция (
noUpdateAvailableилиrollBackToEmbedded). - Ошибкой — запрос некорректен либо приложение/канал не существует.
Всё остальное — как выбирается обновление, что такое канал, как устроен откат — за рамками протокола. Протокол фиксирует только форму ответа.
Как заголовок expo-protocol-version выбирает форму ответа
Клиент присылает expo-protocol-version. Otapush принимает 0 и 1; всё остальное — 400:
{ "error": "Unsupported protocol version. Expected either 0 or 1." }
Отсутствующий заголовок трактуется как 0. Это важно, потому что версии 0 и 1 по-разному говорят «делать нечего»:
| Ситуация | Протокол 0 | Протокол 1 |
|---|---|---|
| Есть обновление | 200, multipart с частью manifest |
200, multipart с частью manifest |
| Клиент уже на свежайшем обновлении | 404 с JSON-телом ошибки |
200, multipart с директивой noUpdateAvailable |
| Для этой платформы/рантайма обновлений нет | 404 с JSON-телом ошибки |
200, multipart с директивой noUpdateAvailable |
| Канал откачен за первое обновление | 200 + rollBackToEmbedded |
200 + rollBackToEmbedded |
Это самое запутанное место протокола, если писать сервер самому. В версии 0 «нет обновления» — это HTTP-ошибка; в версии 1 — успешный ответ, дурную весть несёт тело. Сервер, который отвечает 404 клиенту версии 1, оставляет его с необъяснённым сетевым сбоем вместо чистого «ты актуален», и поведение повторных попыток у клиента меняется соответственно.
Ответ всегда возвращает согласованную версию:
expo-protocol-version: 1
expo-sfv-version: 0
cache-control: private, max-age=0
content-type: multipart/mixed; boundary=----ota-boundary-<uuid>
Одно отступление, о котором стоит знать: Otapush всегда отвечает multipart/mixed, в том числе для протокола 0. Спецификация допускает голое тело application/expo+json, и старые сторонние серверы им пользуются; Otapush — нет. Клиент expo-updates разбирает multipart независимо от запрошенной версии протокола, поэтому на практике это ни разу не помешало, — но если вы тестируете самописным клиентом, ждите multipart.
Что клиент присылает в каждом запросе манифеста
| Заголовок | Пример | Смысл |
|---|---|---|
expo-protocol-version |
1 |
Версия протокола. Отсутствие означает 0 |
expo-platform |
ios / android |
Платформа клиента. Обязателен |
expo-runtime-version |
1.0.0 |
Версия рантайма бинарника. Обязателен, сверяется точно |
expo-current-update-id |
UUID | Обновление, которое клиент выполняет прямо сейчас |
expo-embedded-update-id |
UUID | Обновление, вшитое в бинарник |
expo-expect-signature |
SFV-словарь | Клиент требует подписанный ответ |
expo-extra-params |
SFV-словарь | Пары ключ/значение, заданные клиентом (см. ниже) |
expo-recent-failed-update-ids |
SFV-список | Обновления, недавно упавшие при запуске |
Otapush дополнительно принимает platform и runtime-version как query-параметры — именно поэтому точку можно проверить обычным curl:
curl -i "https://your-server.example.com/api/updates/demo/manifest?platform=ios&runtime-version=1.0.0&deviceId=curl-test" \
-H "expo-protocol-version: 1"
expo-embedded-update-id и expo-recent-failed-update-ids принимаются и игнорируются — Otapush их пока не читает. К чему приводит игнорирование второго, написано ниже, в разделе «Что Otapush записывает, а что нет».
Настоящий ответ с манифестом
Это подлинное тело ответа, снятое с сервера, где в канал prod опубликовано обновление для iOS с версией рантайма 1.0.0 (подпись сокращена по ширине, больше ничего не изменено):
HTTP/1.1 200 OK
expo-protocol-version: 1
expo-sfv-version: 0
cache-control: private, max-age=0
content-type: multipart/mixed; boundary=----ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc
------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc
content-disposition: form-data; name="manifest"
content-type: application/json; charset=utf-8
expo-signature: sig="AwQcjhmgfRrl4HPWlaAXC4ff0grA8mSo…VntcjKA9OaW4CWcThoz7ig==", keyid="main", alg="rsa-v1_5-sha256"
{"id":"ea9bf56f-2bd4-4fad-8d41-f3b817cbe0cb","createdAt":"2026-08-28T11:14:51.545Z","runtimeVersion":"1.0.0","launchAsset":{"hash":"CzoEzLUgFRIP1Haw5sbm7NCF9YZuIn7A89Qwb0uBv1E","key":"0b3a04ccb52015120fd476b0e6c6e6ecd085f5866e227ec0f3d4306f4b81bf51","fileExtension":".js","contentType":"application/javascript","url":"https://ota.example.com/api/updates/demo/assets?asset=bundles%2F5bc457ff%2Fea9bf56f%2Fbundle.js&runtimeVersion=1.0.0&deviceId=dev-123"},"assets":[{"hash":"VB_sVHYq7M9qOe-SnVWLf4iU9DMpEOt9QEjrvDSnhH8","key":"541fec54762aeccf6a39ef929d558b7f8894f4332910eb7d4048ebbc34a7847f","fileExtension":".png","contentType":"image/png","url":"https://ota.example.com/api/updates/demo/assets?asset=assets%2F541fec…847f.png&runtimeVersion=1.0.0&deviceId=dev-123"}],"metadata":{},"extra":{"message":"fix crash on checkout","gitCommit":"9f2c1ab"}}
------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc
content-disposition: form-data; name="extensions"
content-type: application/json
{"assetRequestHeaders":{}}
------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc--
Поле за полем:
id— UUID обновления. Именно он вернётся вexpo-current-update-idв следующем запросе.createdAt— время публикации, ISO 8601. Клиент упорядочивает обновления по нему.runtimeVersion— всегда равен запрошенному; Otapush никогда не отдаёт несовпадающее обновление.launchAsset— JavaScript-бандл.hash— SHA-256 бандла в base64url без паддинга;key— тот же дайджест в hex. Оба обязательны, и это разные кодировки: сервер, положивший hex вhash, провалит проверку целостности на устройстве.assets— картинки, шрифты, всё, на что ссылается бандл, с тем же правилом кодировок.metadata— Otapush всегда отдаёт{}. Поле существует, чтобы сервер мог помечать обновления для клиентской фильтрации через заголовокexpo-manifest-filters; Otapush этот заголовок не шлёт, так что фильтровать не по чему.extra— произвольные данные. Otapush кладёт сюдаmessageиgitCommitпубликации, если они были заданы, — их видно изUpdates.manifestвнутри приложения.
Часть extensions несёт assetRequestHeaders: так сервер может потребовать дополнительные заголовки при скачивании ассетов (например, для схемы с подписанными URL). Otapush отдаёт пустой объект — его ссылки на ассеты самодостаточны.
Почему expo-signature обязан быть строковым item, а не byte sequence
Когда клиент присылает expo-expect-signature, сервер обязан подписать ответ и вернуть подпись в заголовке expo-signature. Подпись покрывает ровно те байты тела этой части, которые ушли в сеть, — строку JSON манифеста или строку JSON директивы. Пересериализация JSON перед проверкой ломает подпись; относитесь к телу как к непрозрачным байтам.
Заголовок — структурированный словарь по RFC 8941. RFC 8941 предлагает два способа нести base64: строку (sig="AwQc…") и byte sequence (sig=:AwQc…:). Byte sequence семантически правильнее, и именно к нему подталкивает внимательное чтение RFC.
И он не работает. expo-updates разбирает заголовок и затем требует, чтобы поле sig было именно строковым item:
val signature = if (sigFieldValue is StringItem) {
sigFieldValue.get()
} else {
throw Exception("Structured field sig not found in expo-signature header")
}
Byte sequence разбирается в item другого типа, попадает в ветку else, и клиент сообщает, что подпись отсутствует, — а не что она некорректна. Обновление отвергается, а текст ошибки указывает не туда. Спецификация не говорит, какой тип item использовать, поэтому узнать это можно только по исходникам клиента.
Поэтому Otapush отдаёт — и на части manifest, и на части directive:
expo-signature: sig="<base64 подписи RSA-SHA256>", keyid="main", alg="rsa-v1_5-sha256"
rsa-v1_5-sha256 — единственный алгоритм, который принимает expo-updates. keyid по умолчанию main с обеих сторон и должен совпадать с codeSigningMetadata.keyid в вашем app.json.
Как клиент это проверяет
- Ключи пер-приложение, генерируются на сервере при создании приложения: пара RSA-2048 плюс настоящий самоподписанный сертификат X.509, выпущенный через
@expo/code-signing-certificates— ту же библиотеку, что использует Expo CLI. - Приватный ключ никогда не покидает сервер. PEM сертификата публичен: он виден в портале, а
otapush initкладёт его в./certs/certificate.pemи прописывает вapp.json. Коммитить этот файл нормально. - Клиент проверяет подпись сертификатом, вшитым в бинарник, поэтому ротация ключа означает выпуск нового бинарника. Планируйте ротацию как релиз в стор, а не как OTA.
- Подпись включается запросом. Если клиент не прислал
expo-expect-signature, Otapush не добавляетexpo-signatureи отдаёт ответ неподписанным — это удобно дляcurlи объясняет, почему в снятом ответе подписи может не быть вовсе.
Почему ключ deviceid в expo-extra-params обязан быть строчными буквами
Otapush тарифицируется по MAU — monthly active users, то есть уникальные устройства за календарный месяц, — поэтому каждому протокольному запросу нужен стабильный идентификатор устройства. Сервер ищет его в трёх местах, в таком порядке:
- query-параметр
deviceId(побеждает, если присутствует несколько источников), - заголовок
x-device-id, - запись
deviceidв структурированном словареexpo-extra-params.
Третий вариант — единственный, который нативный клиент умеет формировать сам, через Updates.setExtraParamAsync. И ключ обязан быть в нижнем регистре:
// Правильно.
await Updates.setExtraParamAsync("deviceid", id);
// Сломано: заголовок до сервера не доедет.
await Updates.setExtraParamAsync("deviceId", id);
RFC 8941 разрешает в ключах словаря только строчные буквы, цифры, _, -, . и *, причём первый символ — строчная буква или *. Сериализатор expo-updates проверяет ровно это:
let failureCondition1 = i == 0 && (c != Character("*") && !c.isLcAlpha)
let failureCondition2 = !(c.isLcAlpha || c.isDigit || c == "_" || c == "-" || c == "." || c == "*")
if failureCondition1 || failureCondition2 {
throw SerializerError.invalidCharacterInKey(key: key, character: c)
}
Сценарий отказа неприятнее, чем падение. На iOS сериализатор бросает исключение при сборке запроса, ошибка ловится и логируется, а весь заголовок Expo-Extra-Params выбрасывается — запрос всё равно уходит, просто без вашего идентификатора. Сервер видит неопознанный запрос, отвечает 400 deviceId is required, и со стороны приложения это выглядит как «сервер обновлений сломался».
Если идентификатора нет вообще, Otapush отвечает:
{
"error": "deviceId is required",
"hint": "Pass a stable device id via the 'deviceId' query parameter or the 'x-device-id' header (expo-updates clients: Updates.setExtraParamAsync('deviceid', id) — key must be lowercase). Set REQUIRE_DEVICE_ID=false to disable this requirement."
}
Разворачиваете сервер сами и не хотите этого требования? Поставьте REQUIRE_DEVICE_ID=false (или 0): запросы без идентификатора обслуживаются как обычно, а их события записываются без привязки к устройству.
Идентификатор обязан быть стабильным между запусками приложения. Новый случайный id на каждый запуск раздувает MAU и способен за сутки выбросить вас за лимит тарифа. Берите постоянный идентификатор — getAndroidId() / getIosIdForVendorAsync() из expo-application, как это сделано в демо-приложении examples/expo-demo/App.tsx. Как всё связать, показано в настройке Expo.
URL ассетов внутри отданного манифеста уже содержат идентификатор запросившего устройства как query-параметр, поэтому скачивания ассетов остаются атрибутируемыми без дополнительной работы на клиенте.
Зачем нужна директива rollBackToEmbedded и что ломается без неё
Вот ловушка. Клиент expo-updates, который уже скачал обновление X, будет запускать X вечно, пока ему не скажут иное. noUpdateAvailable не значит «откатись» — он значит «ты актуален, продолжай». Поэтому если откатить канал в ноль, каждое устройство, успевшее скачать плохой бандл, останется на плохом бандле навсегда, сколько бы раз ни опрашивало сервер.
Ответ протокола — директива rollBackToEmbedded: перестань использовать скачанные обновления и запусти бандл, вшитый в бинарник. Otapush отслеживает это по платформам. Откат единственного обновления канала ставит updateIds[platform] = null и поднимает флаг rollbackToEmbedded[platform], после чего точка манифеста отвечает так:
HTTP/1.1 200 OK
expo-protocol-version: 1
content-type: multipart/mixed; boundary=----ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e
------ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e
content-disposition: form-data; name="directive"
content-type: application/json; charset=utf-8
expo-signature: sig="IOln9cWe0POUZi+9qdXxm7G2Rvfpor0r…", keyid="main", alg="rsa-v1_5-sha256"
{"type":"rollBackToEmbedded"}
------ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e--
Любая последующая публикация или promote в этот канал сбрасывает флаг, и устройства снова идут вперёд — уже от вшитого бандла.
Две честные оговорки:
- Otapush отправляет директиву без поля
parameters.commitTime. Спецификация определяет директиву как{ type, parameters?, extra? }и не документирует, что требуется дляrollBackToEmbedded;expo-updates0.26 принимает голую форму — её Otapush и отдаёт. Если будущая версия клиента начнёт требоватьcommitTime, менять придётся именно это место. - Флаг живёт на паре «канал + платформа», а не на версии рантайма. Как только канал перешёл в откаченное состояние для iOS, iOS-клиент получит
rollBackToEmbeddedдля любой версии рантайма — в том числе для той, для которой в этом канале обновлений никогда не было. На практике это безвредно (таким клиентам нечего откатывать), но это отступление от принципа «ответ на пару (platform, runtimeVersion)», и о нём стоит знать, если вы сравниваете поведение с другим сервером.
Откат не текущего обновления отклоняется с 400. Иначе канал молча перескочил бы через версии, которые владелец никогда не смотрел, — см. rollback в справочнике CLI.
Почему URL ассетов строятся из X-Forwarded-Proto и X-Forwarded-Host
URL в манифесте абсолютные, и клиент запрашивает их ровно как написано. Очевидная реализация — взять origin из request.url — ломается, как только сервер оказывается за обратным прокси, терминирующим TLS.
Traefik (или nginx, или облачный балансировщик) принимает от устройства https://ota.example.com/…, терминирует TLS и передаёт приложению обычный http://ota-api:3000/…. Значит, request.url — это http://, манифест уходит с http://-ссылками на ассеты, и скачивание падает на обеих платформах по своим причинам: Android с API 28 по умолчанию блокирует cleartext HTTP, а iOS App Transport Security блокирует его тоже. При этом сам манифест скачался успешно, так что приложение сообщает о сбое загрузки без внятной причины.
Otapush пересобирает публичный origin из заголовков самого прокси, откатываясь на URL запроса, если их нет:
proto = первое значение X-Forwarded-Proto (иначе: протокол из request.url)
host = первое значение X-Forwarded-Host (иначе: хост из request.url)
origin = proto + "://" + host
Оба заголовка приходят через запятую, если прокси несколько; используется только первое значение. Если вы разворачиваете сервер за собственным прокси, убедитесь, что он выставляет оба, — X-Forwarded-Host часто забывают, и без него ссылки на ассеты указывают на внутреннее имя сервиса вместо вашего домена.
Как адресуются и кэшируются ассеты
GET /api/updates/:slug/assets?asset=bundles/<appId>/<updateId>/bundle.js&runtimeVersion=1.0.0&deviceId=<id>
GET /api/updates/:slug/assets?asset=assets/<sha256hex>.png&runtimeVersion=1.0.0&deviceId=<id>
- Не-бандловые ассеты адресуются по содержимому: ключ хранилища — SHA-256 файла. Два обновления с одной и той же картинкой делят один объект, а устройство, у которого картинка уже есть, пропускает скачивание.
- Бандлы лежат по пути
bundles/<appId>/<updateId>/bundle.js, потому что между обновлениями они не переиспользуются. - Ключи проверяются до любого чтения: ключ, содержащий
/, обязан начинаться сassets/илиbundles/<appId>/и не содержать.., иначе запрос получает403 forbidden asset key. Ключ без слэшей обязан подходить под[a-zA-Z0-9._-]+и разрешается внутриassets/. - Ответы несут
cache-control: public, max-age=31536000, immutable— это безопасно именно потому, что ключ есть хеш содержимого. runtimeVersionпринимается этой точкой для симметрии с URL манифеста; поиск ассета от него не зависит.- Правило про идентификатор устройства действует и здесь. Манифест вшивает его в каждый URL, так что штатному клиенту думать об этом не нужно.
Что Otapush записывает, а что нет
Протокольные точки служат ещё и источником аналитики — отдельного SDK телеметрии нет, и клиент ничего специально не отправляет.
| Событие | Когда записывается |
|---|---|
check |
Любой запрос манифеста, включая закончившиеся noUpdateAvailable |
download |
Любая успешная отдача ассета или бандла |
install |
Проверка, в которой expo-current-update-id указывает на опубликованное обновление — пишется один раз на пару устройство+обновление |
Приём с install стоит проговорить, потому что именно так Otapush считает установки, ничего не спрашивая у приложения: если устройство сообщает, что сейчас выполняет обновление X, значит X скачалось, запустилось и прожило достаточно, чтобы дойти до опроса. Сервер проверяет, что события install для этой пары ещё нет, и записывает его. Вызывать на клиенте ничего не нужно и добавлять в приложение тоже.
События failure сегодня не записываются. Тип события есть в модели данных, эндпоинт статистики его считает, но ни один путь в коде его не пишет — поэтому счётчик отказов в портале сейчас остаётся нулевым. Информация при этом доступна: expo-updates в каждом запросе шлёт expo-recent-failed-update-ids со списком обновлений, упавших при запуске. Otapush этот заголовок пока не читает. Пока это не сделано, для падений на старте используйте систему сбора крашей, а не столбец с отказами, и считайте настоящим сигналом застывший счётчик install после публикации.
MAU считается по различным идентификаторам устройств в разрезе пары «приложение + календарный месяц», upsert выполняется на каждом протокольном запросе. Превышение лимита не останавливает раздачу обновлений — в ответе статистики просто поднимается флаг overLimit. Какие бывают лимиты, см. в тарифах.
Ошибки, которые может вернуть точка манифеста
| Статус | Тело | Причина |
|---|---|---|
400 |
Unsupported protocol version. Expected either 0 or 1. |
expo-protocol-version вне 0/1 |
400 |
Unsupported platform. Expected either ios or android. |
Нет или не распознан expo-platform |
400 |
No runtimeVersion provided. |
Нет expo-runtime-version |
400 |
deviceId is required (+ hint) |
Идентификатора устройства нет ни в одном из трёх мест |
404 |
app not found |
Неизвестный слаг |
404 |
channel '<name>' not found |
Неизвестный query-параметр channel |
404 |
No update available for this platform/runtimeVersion. |
Только протокол 0 — в версии 1 это директива noUpdateAvailable |
Где Otapush не доходит до спецификации
Сказано прямо, чтобы вы поняли, подходит ли вам Otapush, до того как выясните это тяжёлым путём:
expo-manifest-filtersне отправляется. Клиентская фильтрация сохранённых обновлений поmetadataманифеста недоступна, аmetadataвсегда{}.expo-server-defined-headersне отправляется. Механизма, которым сервер заставляет клиента хранить и возвращать свои заголовки, нет.expo-embedded-update-idигнорируется. Сервер не сравнивает вшитое обновление с состоянием канала; эту работу делает точное совпадение версии рантайма.expo-recent-failed-update-idsигнорируется, отсюда и отсутствие событийfailure(см. выше).- Ответы всегда
multipart/mixed, голого манифестаapplication/expo+jsonне бывает. rollBackToEmbeddedидёт безparameters.- Версии рантайма сверяются точно. Ни диапазонов, ни политик сопоставления на сервере нет: обновление, опубликованное для
1.0.0, невидимо для бинарника1.0.1. Это сделано нарочно — это та граница безопасности, которая не пускает JavaScript в нативный бинарник, под который он не собирался.
Если вам нужно что-то из этого списка, помните: единственное, от чего зависит ваш клиент, — протокольная точка. Свой сервер можно поставить по тому же URL, не трогая приложение.
Вопросы и ответы
Нужна ли своя клиентская библиотека, чтобы пользоваться Otapush?
Нет. Otapush — это сервер, а не SDK. Штатный пакет expo-updates, идущий с вашим Expo SDK, и есть весь клиент. Всё специфичное для Otapush — URL, сертификат, версия рантайма — это конфигурация в app.json (или в AndroidManifest.xml и Expo.plist для bare React Native), которую за вас пишет otapush init.
Можно ли перейти с EAS Update на Otapush без изменений в коде приложения?
Да, но понадобится новый бинарник. URL обновлений и сертификат подписи вшиты в приложение, поэтому смена сервера — это релиз в стор. JavaScript при этом не трогается: Updates.checkForUpdateAsync() и остальные работают точно так же.
Одна ли реализация покрывает iOS и Android?
Да. Протокол платформенно-нейтрален, и оба нативных клиента реализуют один и тот же поток. Otapush хранит состояние канала по платформам, поэтому один канал может отдавать разные обновления на iOS и Android — публикация для одной платформы никогда не задевает другую.
Что будет, если runtimeVersion не совпал?
Клиент получит noUpdateAvailable (протокол 1) или 404 (протокол 0) и продолжит работать с тем, что у него есть. Никакого нечёткого сопоставления и никакого отката на «ближайшую версию» нет. Именно этот механизм не даёт JavaScript-бандлу попасть в бинарник, нативный код которого не сможет его выполнить.
Почему устройство получает 400 deviceId is required?
Почти всегда — из-за регистра ключа: Updates.setExtraParamAsync("deviceId", …) вместо "deviceid". На iOS заголовок молча выбрасывается, и запрос приходит вообще без идентификатора. Исправьте ключ — или передайте id как query-параметр deviceId, чтобы подтвердить диагноз за пару секунд.
Обязательна ли подпись кода?
Нет. Если клиент не присылает expo-expect-signature, ответы уходят неподписанными и клиент их принимает. В проектах, настроенных через otapush init, подпись включена по умолчанию, и выключать её в проде незачем: пара ключей генерируется за вас, а приватный ключ остаётся на сервере.
Можно ли проверить протокол через curl?
Да — ради этого платформа и версия рантайма и принимаются как query-параметры:
curl -i "https://your-server.example.com/api/updates/<slug>/manifest?platform=android&runtime-version=1.0.0&deviceId=curl-test" \
-H "expo-protocol-version: 1"
Вы получите настоящее multipart-тело, без подписи. Добавьте -H "expo-current-update-id: <uuid>", чтобы увидеть путь noUpdateAvailable, и -H "expo-expect-signature: true" — чтобы увидеть подписанный ответ.
Сервер сам доставляет обновления на устройства?
Нет. Протокол работает только на вытягивание: клиент опрашивает сервер при запуске (или когда вы вызываете checkForUpdateAsync), сервер отвечает. Push-канала нет, пробуждения тихим уведомлением нет, и заставить обновиться устройство, на котором приложение не запущено, невозможно. «Откатить прямо сейчас» означает «следующий опрос каждого устройства вернёт откат» — устройство, которое приложение не открывает, об этом не узнает никогда.
Как быстро опубликованное обновление доходит до пользователей?
Со скоростью, с которой они открывают приложение, при настройках по умолчанию: expo-updates проверяет обновления при запуске и по умолчанию применяет новый бандл при следующем запуске. Пользователь, открывший приложение дважды, увидит его сразу; тот, кто не открывает вовсе, — никогда. К Otapush эти сроки отношения не имеют: это поведение клиента, а сократить его можно через Updates.reloadAsync().
Что такое id обновления и можно ли его задать?
Это UUID, сгенерированный сервером, и задать его нельзя. Он попадает в манифест, возвращается в expo-current-update-id и служит ключом, к которому привязываются события install. Чтобы связать обновление со своей схемой версий, используйте поля message и gitCommit при публикации — Otapush пробрасывает их в объект extra манифеста.