Skip to content

Commit 1d10cf0

Browse files
aapelivclaude
andcommitted
docs: add mobile Dev Tool per-branch OTA preview design
Records the decision to serve per-branch RN-shell OTA bundles for the Dev Tool statically from the existing S3 + CloudFront dev-assets infra (no OTA server, no code signing), and the dev-launcher source findings that justify loading branches via the launcher deep link rather than the programmatic Updates API (which dev clients hard-disable). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a132318 commit 1d10cf0

1 file changed

Lines changed: 330 additions & 0 deletions

File tree

docs/mobile-dev-tool-ota.md

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
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

Comments
 (0)