KAS Update (OTA)
Bundle distribution layer: manifest endpoint + bsdiff4 differential downloads
- client SDK (TS / iOS / Android) + CDN integration + staged rollout rollout.
Analogous to EAS Update + CodePush — manifest endpoint serves “which bundle hash should this client load”, CDN serves the bytes.
Protocol overview
- Manifest endpoint —
GET /v1/{project}/update/manifest— returns “current bundle hash / URL” for a(platform, channel, runtimeVersion)tuple - Upload bundle —
POST .../bundles— stores bundle bytes + computes bsdiff4 patches against the previously-current bundle - CDN — bundles served from Aliyun OSS + CDN (hash-addressed, immutable)
- Promote —
POST .../bundles/{hash}/promote— rotate the “current” pointer for a channel / platform / runtimeVersion; whatkeel publishcalls
Equivalent to EAS Update 1:1 on channel / branch / runtimeVersion semantics — Keel V1 is feature-parity
API surface
1. Manifest endpoint
GET /v1/{project}/update/manifest
?platform=ios
&runtime_version=1.2.0 # SDK ABI version
&channel=production # default "production"
¤t_hash=sha256:xxx # optional — client's loaded bundle hash
Returns 200:
{
"manifest_id": "m_abc123",
"current": {
"version": "v1714000000-abc...",
"url": "https://cdn.keel.appunvs.com/<project>/<hash>.bundle.zip",
"content_hash": "sha256:...",
"size_bytes": 123456,
"runtime_version": "1.2.0",
"channel": "production",
"expires_at": 1714036000000
},
"no_update": false
}
Returns 200 + {no_update: true} when client’s hash matches. Returns 404
when no bundle has been promoted for this tuple yet.
2. Bundle upload
POST /v1/{project}/update/bundles
Authorization: Bearer <key>
V2 push trigger — Jiguang / Getui server API integration (V2 follow-up).
Self-hosting the update-server
Source: keel/cloud/cmd/update-server/
Run
cd keel
go run ./cmd/update-server # dev defaults: :8081, ./data/bundles, mem store
go run ./cmd/update-server -listen :9090
go run ./cmd/update-server \
-blob-root /var/keel/bundles \
-url-base https://api.keel.appunvs.com/v1/_bundles \
-db sqlite:./data/keel-update.db
Environment variables
| Env var | Flag | Default | Notes |
|---|---|---|---|
KEEL_UPDATE_LISTEN | -listen | :8081 | |
KEEL_UPDATE_MODE | -mode | debug | debug / release / test |
KEEL_UPDATE_DB | -db | mem | mem or sqlite:<path> |
KEEL_UPDATE_AUTH_KEYS_FILE | -auth-keys-file | — | JSON file of API keys |
KEEL_UPDATE_AUTH_KEYS | -auth-keys | — | Inline: key:project[:label],... |
KEEL_UPDATE_PATCHES | -patches | on | Set off to disable bsdiff4 |
KEEL_UPDATE_BLOB_STORE | — | localfs | localfs or s3 |
KEEL_UPDATE_BLOB_ROOT | -blob-root | ./data/bundles | LocalFS only |
KEEL_UPDATE_URL_BASE | -url-base | — | Public URL prefix (LocalFS) |
S3-compatible blob store (production)
KEEL_UPDATE_BLOB_STORE=s3
KEEL_UPDATE_S3_BUCKET=appunvs-update-prod
KEEL_UPDATE_S3_REGION=cn-hangzhou
KEEL_UPDATE_S3_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
KEEL_UPDATE_S3_ACCESS_KEY=...
KEEL_UPDATE_S3_SECRET_KEY=...
KEEL_UPDATE_PUBLIC_URL_BASE=https://update-cdn.appunvs.com
Works with Aliyun OSS, AWS S3, Cloudflare R2, MinIO. Bucket layout:
bundles/<hex> and patches/<projectHex>/<from>_<to>.bsdiff4 — same schema
as LocalFS so switching doesn’t invalidate in-flight URLs.
Manifest store backends
| DSN | Store | Use case |
|---|---|---|
mem (default) | In-memory map | Dev / tests. Lost on restart |
sqlite:<path> | Single-file SQLite (pure-Go, WAL) | Self-host single node |
(V1 GA) postgres://... | Postgres | Multi-node HA |
Auth
Developer-facing routes (upload / promote / list) require
Authorization: Bearer <key>. Public routes — manifest poll and bundle
download — stay open (hit from end-user devices).
Default: no auth (local dev). Never ship a public endpoint without keys.
Inline keys:
go run ./cmd/update-server \
-auth-keys "keel_live_alice:proj1:alice,keel_local_dev:*:dev"
File keys (-auth-keys-file keys.json):
{
"keys": [
{"key": "keel_live_a3f...", "project": "myproj", "label": "production CI"},
{"key": "keel_admin_xyz", "project": "*", "label": "admin"}
]
}
Project * = wildcard (admin key). CLI reads key from keel.json’s
api_key, KEEL_API_KEY env var, or --api-key flag.
HTTP API reference
| Method | Route | Auth | Notes |
|---|---|---|---|
GET | /v1/{project}/update/manifest | — | Public; poll from device |
POST | /v1/{project}/update/bundles | ✅ | Upload bundle (JSON+base64) |
POST | /v1/{project}/update/bundles/{hash}/promote | ✅ | Rotate “current” pointer |
GET | /v1/{project}/update/bundles | ✅ | List bundles (?channel=...) |
GET | /v1/{project}/update/bundles/{hash} | — | Download bytes (LocalFS) |
Promote request body
{
"platform": "ios",
"channel": "production",
"runtime_version": "1.0"
}
Smoke test
go run ./cmd/update-server -listen :19191 -blob-root /tmp/keel-bundles &
PAYLOAD="hello bundle"
HASH_HEX=$(echo -n "$PAYLOAD" | sha256sum | cut -d' ' -f1)
B64=$(echo -n "$PAYLOAD" | base64)
# Upload
curl -X POST http://localhost:19191/v1/myproj/update/bundles \
-H 'Content-Type: application/json' \
-d "{\"version\":\"v1.0.0\",\"content_hash\":\"sha256:$HASH_HEX\",\"platform\":\"ios\",\"channel\":\"production\",\"runtime_version\":\"1.0\",\"size_bytes\":12,\"bytes_base64\":\"$B64\"}"
# Promote
curl -X POST http://localhost:19191/v1/myproj/update/bundles/$HASH_HEX/promote \
-H 'Content-Type: application/json' \
-d '{"platform":"ios","channel":"production","runtime_version":"1.0"}'
# Manifest
curl 'http://localhost:19191/v1/myproj/update/manifest?platform=ios&channel=production&runtime_version=1.0'
Status (V1 → V1 GA)
- ✅ HTTP API: manifest / upload / promote / list / download
- ✅ In-memory ManifestStore (
mem) for dev / tests - ✅ SQLite ManifestStore (
sqlite:<path>) — pure-Go viamodernc.org/sqlite - ✅ LocalFSBlobStore
- ✅ S3-compatible BlobStore (Aliyun OSS / AWS S3 / R2 / MinIO)
- ✅ API key auth on developer routes; manifest + download public
- ✅ E2E test coverage
- ❌ Multipart upload (V2)
- ❌ Postgres ManifestStore (V1 GA, for HA / multi-node)
- ❌ Push trigger (Jiguang / Getui, V2)
- ❌ HA / cluster mode (V2)
Client SDK integration — @keel-ai/updates
OTA client lives in its own npm package (keel/packages/updates/), mirroring
the expo-updates shape. Three parallel implementations of the same
protocol — TS / Swift / Kotlin — each self-contained for its consumer:
TypeScript
import { KeelUpdateClient } from '@keel-ai/updates';
const client = new KeelUpdateClient({
endpoint: 'https://api.keel.appunvs.com',
project: 'myproj',
channel: 'production',
});
// On app foreground:
const result = await client.check();
if (result.kind === 'update_available') {
await client.download(result.bundle);
await client.apply();
// ... reload the JS engine
}
iOS (KeelUpdate pod, import KeelUpdate)
For iOS host shells that handle OTA in their native launcher (e.g. fetch
the next bundle before mounting KeelView). KeelUpdateClient polls
manifest on foreground, downloads via URLSession with resume support,
verifies sha256 before swap.
Android (keel-updates.aar, package com.keel.updates)
KeelUpdateClient(endpoint, project, channel).check() — same polling +
download + verify flow as iOS, OkHttp-backed.
staged rollout (canary rollout)
V1 GA: promote to staging channel first; subset of users on staging
receives update, rest stay on production pointer.
V2: numeric rollout percentage in the promote request body
("rollout_percent": 10); manifest endpoint uses device ID hash for
deterministic bucketing.
See also
deployment.md— production infra (OSS + CDN config)deployment-sop.md— release checklistgithub-actions.md—keel-ota-publish.ymltemplatekeel/cloud/cmd/update-server/— server sourcekeel/packages/updates/—@keel-ai/updatesnpm package (TS / Swift / Kotlin clients)