Machine-translated draft — terminology + flow still being reviewed.

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

  1. Manifest endpointGET /v1/{project}/update/manifest — returns “current bundle hash / URL” for a (platform, channel, runtimeVersion) tuple
  2. Upload bundlePOST .../bundles — stores bundle bytes + computes bsdiff4 patches against the previously-current bundle
  3. CDN — bundles served from Aliyun OSS + CDN (hash-addressed, immutable)
  4. PromotePOST .../bundles/{hash}/promote — rotate the “current” pointer for a channel / platform / runtimeVersion; what keel publish calls

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"
  &current_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 varFlagDefaultNotes
KEEL_UPDATE_LISTEN-listen:8081
KEEL_UPDATE_MODE-modedebugdebug / release / test
KEEL_UPDATE_DB-dbmemmem or sqlite:<path>
KEEL_UPDATE_AUTH_KEYS_FILE-auth-keys-fileJSON file of API keys
KEEL_UPDATE_AUTH_KEYS-auth-keysInline: key:project[:label],...
KEEL_UPDATE_PATCHES-patchesonSet off to disable bsdiff4
KEEL_UPDATE_BLOB_STORElocalfslocalfs or s3
KEEL_UPDATE_BLOB_ROOT-blob-root./data/bundlesLocalFS only
KEEL_UPDATE_URL_BASE-url-basePublic 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

DSNStoreUse case
mem (default)In-memory mapDev / tests. Lost on restart
sqlite:<path>Single-file SQLite (pure-Go, WAL)Self-host single node
(V1 GA) postgres://...PostgresMulti-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

MethodRouteAuthNotes
GET/v1/{project}/update/manifestPublic; poll from device
POST/v1/{project}/update/bundlesUpload bundle (JSON+base64)
POST/v1/{project}/update/bundles/{hash}/promoteRotate “current” pointer
GET/v1/{project}/update/bundlesList 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 via modernc.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 checklist
  • github-actions.mdkeel-ota-publish.yml template
  • keel/cloud/cmd/update-server/ — server source
  • keel/packages/updates/@keel-ai/updates npm package (TS / Swift / Kotlin clients)