|
| 1 | +# Mobile Dev Tool — per-branch OTA previews |
| 2 | + |
| 3 | +How we let anyone open a specific mobile branch on a phone by scanning a QR in |
| 4 | +the PR, **without** an App Store / TestFlight build per branch, and without |
| 5 | +paying for EAS Update. |
| 6 | + |
| 7 | +**Status:** design decided, implementation in progress. Scope is the **Dev Tool |
| 8 | +build only** — never production. |
| 9 | + |
| 10 | +This supersedes the earlier exploration in `tools/lambdas/expo-ota.md` (which |
| 11 | +weighed a hand-rolled edge function vs. the `expo-open-ota` server). We evaluated |
| 12 | +deploying `expo-open-ota` at `ota.couchershq.org`, then concluded a **static** |
| 13 | +serve off our existing S3 + CloudFront is simpler for a dev-only tool. The |
| 14 | +reasoning and the source-level findings that justify it are recorded below. |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## 1. What the Dev Tool has to do |
| 19 | + |
| 20 | +The Dev Tool (`devtool` variant in `app/mobile/app.config.js`, `devtool` profile |
| 21 | +in `app/mobile/eas.json`) is one installed app that must do **all** of the |
| 22 | +following at once, because that's how it earns its keep across the whole team: |
| 23 | + |
| 24 | +| Workflow | Share of work | How it's served | |
| 25 | +| --- | --- | --- | |
| 26 | +| **Local Metro live-reload** — point the installed app at a dev's `expo start` for instant reload, no per-device native build | ~95% of dev work | dev launcher loads a Metro URL | |
| 27 | +| **Web preview** — repoint the in-app WebView at a Vercel branch URL | ~65% of PRs | in-app dev settings (`config/urls.ts` override) | |
| 28 | +| **RN-shell OTA** — load a branch's prebuilt JS shell (Expo Router screens, `WebEmbed`, nav) | ~30% of PRs | dev launcher loads our manifest URL ← **this doc** | |
| 29 | +| **Native change** — new native module / dep | ~5% | full native build by senior staff (they build anyway) | |
| 30 | + |
| 31 | +Two consequences drive the whole design: |
| 32 | + |
| 33 | +1. **It must stay a dev client** (`developmentClient: true`). The live-reload |
| 34 | + workflow is the Dev Tool's main reason to exist; that requires the dev |
| 35 | + launcher. So the OTA mechanism has to work *inside a dev client*, not assume a |
| 36 | + release build. |
| 37 | +2. **The web axis and the RN-shell axis are orthogonal.** The RN shell is the |
| 38 | + native-JS bundle (delivered OTA); the web/API target the WebView loads is set |
| 39 | + separately at runtime via the existing dev-settings override. A QR can carry |
| 40 | + one, the other, or both. |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## 2. Key finding: how OTA reaches a dev client |
| 45 | + |
| 46 | +A dev client **hard-disables the programmatic Updates API**. In |
| 47 | +`expo-updates`, `UpdatesDevLauncherController` throws |
| 48 | +`NotAvailableInDevClientException` for `fetchUpdateAsync`, `checkForUpdateAsync`, |
| 49 | +`setUpdateURLAndRequestHeadersOverride`, and `setUpdateRequestHeadersOverride`. |
| 50 | +So we **cannot** swap branches by calling those at runtime. |
| 51 | + |
| 52 | +Instead, branches load through the **dev launcher's own load path** |
| 53 | +(`EXDevLauncherController.loadApp`) — the *same* machinery that loads a Metro |
| 54 | +server. You hand it a URL via the deep link: |
| 55 | + |
| 56 | +``` |
| 57 | +couchers-devtool://expo-development-client/?url=<url-encoded target> |
| 58 | +``` |
| 59 | + |
| 60 | +where the target is **either** a Metro URL (live reload) **or** one of our |
| 61 | +manifest URLs (branch OTA). The launcher probes the URL |
| 62 | +(`EXDevLauncherManifestParser`): a `HEAD` to classify it, then a `GET` to read |
| 63 | +the manifest, then it loads the update via `fetchUpdateWithConfiguration`. |
| 64 | + |
| 65 | +**What the launcher sends on those requests** (from |
| 66 | +`EXDevLauncherUpdatesHelper.m` / `DevLauncherUpdatesHelper.kt` / |
| 67 | +`EXDevLauncherManifestParser.m`): |
| 68 | + |
| 69 | +- `expo-platform: ios|android` |
| 70 | +- `Expo-Updates-Environment: DEVELOPMENT` |
| 71 | +- `Expo-Dev-Client-ID: <installation id>` |
| 72 | +- `expo-runtime-version: <the installed build's fingerprint>` |
| 73 | +- `accept: application/expo+json,application/json` |
| 74 | + |
| 75 | +**What it does NOT send:** `expo-channel-name`. It also does **not** forward the |
| 76 | +`updates.requestHeaders` configured in `app.config.js`. Therefore **the branch |
| 77 | +must be fully identified by the URL itself** — we cannot rely on a channel |
| 78 | +header or on channel→branch resolution. This is *why* a static per-URL serve fits |
| 79 | +the dev-client model perfectly: every branch is just a distinct, immutable URL. |
| 80 | + |
| 81 | +> This is also why deploying `expo-open-ota` as-is did not work for the dev |
| 82 | +> client: its `/manifest` handler reads the channel **only** from the |
| 83 | +> `expo-channel-name` header (no path/query fallback) and 400s without it, and |
| 84 | +> the launcher never sends that header. We'd have had to patch the server. |
| 85 | +
|
| 86 | +--- |
| 87 | + |
| 88 | +## 3. Why static — no OTA server, no code signing |
| 89 | + |
| 90 | +- **The wire format is frozen.** The Expo Updates protocol is a published, |
| 91 | + versioned spec ("v1", pinned by the `expo-protocol-version` header). The |
| 92 | + manifest shape, the `multipart/mixed` framing, the request headers, and the |
| 93 | + signing scheme don't drift with Expo SDK upgrades. Multiple independent servers |
| 94 | + (`expo-open-ota`, `xavia-ota`, Expo's own `custom-expo-updates-server` example) |
| 95 | + interoperate with shipping apps precisely because it's stable. So generating |
| 96 | + the manifest ourselves is not a moving-target maintenance burden. |
| 97 | +- **What *is* SDK-coupled isn't ours to write.** The fingerprint |
| 98 | + (`runtimeVersion`) and the JS bundle are produced by `expo export` / |
| 99 | + `@expo/fingerprint`, which we invoke from the same repo+lockfile that built the |
| 100 | + app — so both sides agree by construction. We call a CLI; we don't reimplement |
| 101 | + it. |
| 102 | +- **Signing is the easy part, and unnecessary here.** Code signing only kicks in |
| 103 | + when `app.config.js` sets `codeSigningCertificate`. Its job is to stop a |
| 104 | + compromised server injecting malicious JS into a *production-signed* app. For an |
| 105 | + internally distributed dev/QA tool fetching branch bundles over HTTPS, TLS |
| 106 | + already covers that threat. So we **drop signing for the Dev Tool** and serve |
| 107 | + plain manifests. (Signing comes back if/when prod OTA is on the table — see §10.) |
| 108 | + |
| 109 | +Net: the OTA server's value was convenience (`eoas publish`, channel mapping, |
| 110 | +dashboard, rollback), not protocol correctness. For one dev-only flow, a small CI |
| 111 | +script + the S3/CloudFront we already run is fewer moving parts. |
| 112 | + |
| 113 | +--- |
| 114 | + |
| 115 | +## 4. The Expo Updates protocol, minimally |
| 116 | + |
| 117 | +**Request:** `GET <url>` with the headers in §2. |
| 118 | + |
| 119 | +**Response — the manifest** (protocol v1, served as one `multipart/mixed` part; |
| 120 | +the JSON body shape): |
| 121 | + |
| 122 | +```json |
| 123 | +{ |
| 124 | + "id": "<uuid for this update>", |
| 125 | + "createdAt": "2026-05-23T12:00:00.000Z", |
| 126 | + "runtimeVersion": "<fingerprint — must match the request>", |
| 127 | + "launchAsset": { |
| 128 | + "key": "bundle-<hash>", |
| 129 | + "contentType": "application/javascript", |
| 130 | + "url": "https://<sha>--ota.preview.couchershq.org/ios/bundle.hbc" |
| 131 | + }, |
| 132 | + "assets": [ |
| 133 | + { "key": "<hash>", "contentType": "image/png", |
| 134 | + "url": "https://<sha>--ota.preview.couchershq.org/ios/assets/<hash>" } |
| 135 | + ], |
| 136 | + "metadata": {}, |
| 137 | + "extra": { "expoClient": { "...": "from dist/expoConfig.json" } } |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +The update is **content-addressed**: the client compares the manifest `id` |
| 142 | +against what it's running and no-ops if equal. Crucially, if |
| 143 | +`runtimeVersion` does **not** match the installed build's fingerprint, the client |
| 144 | +**ignores the update**. That is our safety net — a branch that changed native |
| 145 | +deps has a different fingerprint and simply won't load OTA (it needs a native |
| 146 | +build, the 5% case). |
| 147 | + |
| 148 | +--- |
| 149 | + |
| 150 | +## 5. Reusing the dev-assets infra |
| 151 | + |
| 152 | +We already serve web previews from S3 + CloudFront via two Lambda@Edge functions |
| 153 | +(must stay in `us-east-1`, where the bucket and functions already live): |
| 154 | + |
| 155 | +- `tools/lambdas/preview-viewer-request.js` — **host → S3 key prefix.** |
| 156 | + `resolvePath(host)` maps `next.couchershq.org → /web/develop`, and |
| 157 | + `<a>--<b>.preview.couchershq.org → /<b>/<a>`, then prepends that folder to the |
| 158 | + request URI. |
| 159 | +- `tools/lambdas/preview-origin-response.js` — SPA 404 → `index.html` fallback. |
| 160 | + |
| 161 | +**OTA slots into the existing viewer-request with zero changes.** Pick the host |
| 162 | +pattern `<sha>--ota.preview.couchershq.org`: |
| 163 | + |
| 164 | +- `resolvePath("<sha>--ota.preview.couchershq.org")` → `db1=<sha>`, `db2=ota` → |
| 165 | + `/ota/<sha>`. |
| 166 | +- A request for `/ios/manifest` becomes S3 key `/ota/<sha>/ios/manifest`. |
| 167 | +- The web-SPA special-case (`folder.startsWith("/web/")`) doesn't fire for |
| 168 | + `/ota/...`, and root→`preview.txt` doesn't apply. So it just works. |
| 169 | + |
| 170 | +`<sha>` is the **git commit SHA** the CI job published (URL-safe; branch names |
| 171 | +with slashes are not). Humans never type it — the PR comment encodes the full URL |
| 172 | +in a QR. |
| 173 | + |
| 174 | +**Per-SHA content is immutable** → CloudFront can cache forever and **no |
| 175 | +invalidation is ever needed**. (This is the big simplification over a mutable |
| 176 | +`latest.json` pointer.) |
| 177 | + |
| 178 | +### Platform: one URL or two? |
| 179 | + |
| 180 | +A manifest is per-platform (one `launchAsset`). Two options: |
| 181 | + |
| 182 | +- **No Lambda change (start here):** publish both platforms and use |
| 183 | + platform-specific URLs — `/ios/manifest` and `/android/manifest`. The PR |
| 184 | + comment renders an iOS QR and an Android QR. |
| 185 | +- **One-URL nicety (optional):** extend the viewer-request Lambda so that when |
| 186 | + `db2 === "ota"` it reads the `expo-platform` request header and rewrites to |
| 187 | + `/ota/<sha>/<platform>/manifest`. Then a single QR works on both. Small, isolated |
| 188 | + change (gated on the `ota` host), but requires CloudFront to forward |
| 189 | + `expo-platform` to the function. |
| 190 | + |
| 191 | +### Manifest response headers / framing |
| 192 | + |
| 193 | +Target protocol v1: store the manifest object as a `multipart/mixed` body with |
| 194 | +`Content-Type: multipart/mixed; boundary=<b>` (set at upload via |
| 195 | +`aws s3 cp --content-type`). S3 cannot emit a real `expo-protocol-version: 1` |
| 196 | +response header on its own — if the client requires it, add a tiny |
| 197 | +**origin-response Lambda@Edge** that injects it, mirroring |
| 198 | +`preview-origin-response.js`. |
| 199 | + |
| 200 | +> ⚠️ **Empirical check (do this on-device before finalizing):** confirm what |
| 201 | +> `expo-updates ~29` (SDK 54) actually requires from the dev-launcher load path. |
| 202 | +> If it accepts a protocol-0 `application/expo+json` JSON manifest, we can skip |
| 203 | +> the origin-response Lambda entirely. If it demands v1 multipart + the response |
| 204 | +> header, add the one-function origin-response Lambda. The failure mode is a |
| 205 | +> generic "Couldn't parse the manifest" alert with no detail, so test early. |
| 206 | +
|
| 207 | +--- |
| 208 | + |
| 209 | +## 6. S3 layout (in the existing `couchers-dev-assets` bucket) |
| 210 | + |
| 211 | +``` |
| 212 | +ota/ |
| 213 | + <sha>/ # git commit SHA — immutable |
| 214 | + ios/ |
| 215 | + manifest # multipart/mixed body, no signature |
| 216 | + bundle.hbc |
| 217 | + assets/<hash> |
| 218 | + android/ |
| 219 | + manifest |
| 220 | + bundle.hbc |
| 221 | + assets/<hash> |
| 222 | +``` |
| 223 | + |
| 224 | +All asset/bundle URLs baked into the manifest point back at |
| 225 | +`https://<sha>--ota.preview.couchershq.org/<platform>/...`, served as plain files. |
| 226 | + |
| 227 | +--- |
| 228 | + |
| 229 | +## 7. CI publish job |
| 230 | + |
| 231 | +A job (GitLab CI, gated on `app/mobile/**` changes; or a GitHub Action under |
| 232 | +`tools/.github/workflows/` reusing the existing Lambda-deploy AWS creds pattern) |
| 233 | +that, per mobile PR: |
| 234 | + |
| 235 | +1. For each platform: `APP_VARIANT=devtool npx expo export --platform <p>` → |
| 236 | + `dist/` (bundle at `dist/_expo/static/js/<p>/index-<hash>.hbc`, |
| 237 | + `dist/assets/<hash>`, `dist/metadata.json`, `dist/expoConfig.json`). |
| 238 | +2. Compute the fingerprint `runtimeVersion` (`npx @expo/fingerprint` / |
| 239 | + `npx expo-updates fingerprint:generate --platform <p>` — verify the exact |
| 240 | + invocation for our toolchain). It must equal the installed Dev Tool build's |
| 241 | + fingerprint. |
| 242 | +3. Generate the manifest from `metadata.json` + `expoConfig.json`: mint a UUID |
| 243 | + `id`, set `runtimeVersion`, compute each asset's base64url SHA-256, and rewrite |
| 244 | + every path to its `<sha>--ota.preview.couchershq.org` URL. Wrap as |
| 245 | + `multipart/mixed`. |
| 246 | +4. `aws s3 cp` the bundle, assets, and manifest under `ota/<sha>/<platform>/`, |
| 247 | + with correct `--content-type` on each. |
| 248 | +5. No CloudFront invalidation needed (immutable keys). |
| 249 | +6. Post / update the PR comment with the QR(s) — see §9. |
| 250 | + |
| 251 | +--- |
| 252 | + |
| 253 | +## 8. App-config (launcher) changes |
| 254 | + |
| 255 | +In `app/mobile/app.config.js`, **devtool variant only**: |
| 256 | + |
| 257 | +- **Keep** `runtimeVersion: { policy: "fingerprint" }` and `updates.enabled: true` |
| 258 | + (the launcher uses the fingerprint to gate updates). |
| 259 | +- **Remove** `codeSigningCertificate` + `codeSigningMetadata` (no signing — §3). |
| 260 | + Correspondingly revert the `.gitignore` exception and the committed |
| 261 | + `certs/certificate.pem`; they were for the server approach. |
| 262 | +- The per-branch update URL is supplied **at load time** via the deep link, not |
| 263 | + baked into config. An embedded `updates.url` is only the launch-time default; |
| 264 | + for the Dev Tool we want it to boot to the launcher/menu, and branch loads come |
| 265 | + from the QR. |
| 266 | +- **Stay a dev client** (`developmentClient: true`) — do not flip to a release |
| 267 | + build. |
| 268 | + |
| 269 | +`eas.json`'s `channel: "development"` on the devtool profile is vestigial for the |
| 270 | +static approach (channels only matter for EAS Update) but harmless. |
| 271 | + |
| 272 | +Production and staging stay on EAS Update (`u.expo.dev`) untouched. |
| 273 | + |
| 274 | +--- |
| 275 | + |
| 276 | +## 9. PR comment + QR |
| 277 | + |
| 278 | +The phone camera opens a custom-scheme deep link; the existing |
| 279 | +`app/mobile/dev-url-qr.html` already proves this pattern for the web/API axis. |
| 280 | +Per PR, the comment renders QR(s) by change type: |
| 281 | + |
| 282 | +- **Web-only PR** → set the WebView target, no OTA: |
| 283 | + `couchers-devtool://dev-settings?api=<dev-api>&web=<vercel preview url>` |
| 284 | + (handled by `app/mobile/app/dev-settings.tsx` → `config/urls.ts` override, |
| 285 | + persisted in AsyncStorage). |
| 286 | +- **RN-shell PR** → load the branch bundle: |
| 287 | + `couchers-devtool://expo-development-client/?url=<encoded manifest url>` |
| 288 | + (`https://<sha>--ota.preview.couchershq.org/<platform>/manifest`), one QR per |
| 289 | + platform unless the one-URL Lambda nicety (§5) is in place. |
| 290 | + |
| 291 | +The two axes compose: scanning the RN-shell QR loads the branch's native shell; |
| 292 | +the WebView inside it uses whatever web/API override is persisted (or the |
| 293 | +devtool's configured `EXPO_PUBLIC_WEB_BASE_URL`). To pin both, set the |
| 294 | +dev-settings link first, then load the bundle. |
| 295 | + |
| 296 | +--- |
| 297 | + |
| 298 | +## 10. Constraints, gotchas, open items |
| 299 | + |
| 300 | +- **Fingerprint match is the #1 failure cause.** The exported `runtimeVersion` |
| 301 | + must equal the installed Dev Tool build's, or the client silently ignores the |
| 302 | + update. JS-only branches built from the same native config match |
| 303 | + deterministically; native changes don't (by design — the safety net). |
| 304 | +- **CloudFront must forward the `expo-*` headers** to origin/function as needed, |
| 305 | + and not cache the manifest keyed in a way that ignores platform. Per-SHA keys |
| 306 | + are immutable, so caching is otherwise free. |
| 307 | +- **Empirical checks remaining:** (a) the protocol/response-header question in §5; |
| 308 | + (b) confirm the launcher accepts our static manifest end-to-end on a real |
| 309 | + device against the bucket; (c) the exact `@expo/fingerprint` invocation for our |
| 310 | + toolchain. |
| 311 | +- **Going to production (out of scope now):** would add code signing (private key |
| 312 | + signs manifests, build embeds the public cert), staged rollouts, and instant |
| 313 | + rollback (protocol v1 directives). At that point reconsider `expo-open-ota` |
| 314 | + rather than growing the edge functions — it provides channels, rollouts, |
| 315 | + rollback, and signing out of the box and can use the same bucket. |
| 316 | + |
| 317 | +--- |
| 318 | + |
| 319 | +## References |
| 320 | + |
| 321 | +- Expo Updates protocol spec: https://docs.expo.dev/technical-specs/expo-updates-1/ |
| 322 | +- Custom updates server guide: https://docs.expo.dev/distribution/custom-updates-server/ |
| 323 | +- `expo-open-ota` (evaluated, not adopted for the dev tool): https://github.com/axelmarciano/expo-open-ota |
| 324 | +- Earlier exploration: `tools/lambdas/expo-ota.md` |
| 325 | +- Existing edge functions: `tools/lambdas/preview-viewer-request.js`, `tools/lambdas/preview-origin-response.js` |
| 326 | +- App config / channels: `app/mobile/app.config.js`, `app/mobile/eas.json` |
| 327 | +- In-app web/API override: `app/mobile/config/urls.ts`, `app/mobile/app/dev-settings.tsx` |
| 328 | +- QR pattern precedent: `app/mobile/dev-url-qr.html` |
| 329 | +- Dev-launcher load path (source): `app/mobile/node_modules/expo-dev-launcher/ios/EXDevLauncherController.m`, `.../EXDevLauncherUpdatesHelper.m`, `.../Manifest/EXDevLauncherManifestParser.m`, `.../android/.../helpers/DevLauncherUpdatesHelper.kt` |
| 330 | +- Dev-client API disablement (source): `app/mobile/node_modules/expo-updates/.../UpdatesDevLauncherController.kt` |
0 commit comments