open-ota 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonardo E. Dominguez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # expo-ota-cli
2
+
3
+ Publishes an Expo app's JS bundle to a self-hosted [Expo OTA](https://github.com/focux/expo-ota)
4
+ server. Run it from the app directory after deploying the server.
5
+
6
+ ```sh
7
+ npx expo-ota-cli publish --branch staging --message "Fix payment sheet"
8
+ ```
9
+
10
+ It runs `expo export` for both platforms, resolves each platform's runtime version, uploads only the
11
+ assets the server does not already have, publishes the update group, then builds bsdiff patches
12
+ against the newest three bundles on the branch and uploads them (`bsdiff` must be on the PATH,
13
+ otherwise patches are skipped with a warning).
14
+
15
+ ## Credentials
16
+
17
+ | Setting | Env var | Flag | Value |
18
+ | --- | --- | --- | --- |
19
+ | Server origin | `OTA_URL` | `--server` | The `updatesUrl` printed by the server deploy, for example `https://u.example.com` |
20
+ | Publish token | `OTA_PUBLISH_TOKEN` | `--token` | The `OTA_PUBLISH_TOKEN` the server was deployed with |
21
+ | Actor | `OTA_ACTOR` | | Optional. Who the publish is credited to; defaults to the commit author |
22
+
23
+ Flags win over environment variables. In CI, store the token as a secret and the origin as a
24
+ variable. Locally, export them in your shell or put them in a `.env` your shell loads; the CLI does
25
+ not read `.env` files itself.
26
+
27
+ ## Commands
28
+
29
+ ```
30
+ expo-ota publish --branch <name> [--message <text>] [--rollout <0-100>]
31
+ [--platform ios] [--platform android] [--skip-export] [--dist <dir>]
32
+ [--no-patches] [--project <dir>] [--server <url>] [--token <token>]
33
+ expo-ota rollback-to-embedded --branch <name> [--platform ios|android] [--message <text>]
34
+ expo-ota --help
35
+ ```
36
+
37
+ `--rollout` starts the group as a canary; the remaining devices keep the previous update until the
38
+ rollout is widened in the dashboard. `rollback-to-embedded` publishes a group that sends every
39
+ device on the branch back to the JS baked into its build.
40
+
41
+ Requires Node 20 or newer and the Expo CLI of the app it runs in.
package/dist/cli.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { run } from "./expo.js";
5
+ import { publish, rollbackToEmbedded } from "./publish.js";
6
+ const usage = `expo-ota - publish Expo updates to a self-hosted Expo OTA server
7
+
8
+ Usage:
9
+ expo-ota publish --branch <name> [options]
10
+ expo-ota rollback-to-embedded --branch <name> [options]
11
+
12
+ Commands:
13
+ publish Export the project and publish an update group.
14
+ rollback-to-embedded Publish a group that sends clients back to their embedded update.
15
+
16
+ Options:
17
+ --branch <name> Branch to publish to. Required.
18
+ --message <text> Message stored with the group.
19
+ --rollout <0-100> Percent of clients that get the update. Publish only.
20
+ --platform <name> ios or android. Repeatable. Defaults to both.
21
+ --dist <dir> Export output directory, relative to the project. Publish only. Defaults to dist.
22
+ --skip-export Reuse an existing export instead of running expo export. Publish only.
23
+ --no-patches Skip the bsdiff delta patches built after publishing. Publish only.
24
+ --project <dir> Expo project directory. Defaults to the working directory.
25
+ --server <url> Server origin. Overrides OTA_URL.
26
+ --token <token> Publish token. Overrides OTA_PUBLISH_TOKEN.
27
+ -h, --help Show this help.
28
+
29
+ Environment:
30
+ OTA_URL Server origin, for example https://u.example.com
31
+ OTA_PUBLISH_TOKEN Bearer token for the publish endpoints, the OTA_PUBLISH_TOKEN the server was deployed with.
32
+ OTA_ACTOR Who to credit the publish to. Defaults to the commit author.
33
+ `;
34
+ const main = async () => {
35
+ const { values, positionals } = parseArgs({
36
+ args: process.argv.slice(2),
37
+ allowPositionals: true,
38
+ options: {
39
+ branch: { type: "string" },
40
+ message: { type: "string" },
41
+ rollout: { type: "string" },
42
+ platform: { type: "string", multiple: true },
43
+ dist: { type: "string" },
44
+ "skip-export": { type: "boolean" },
45
+ "no-patches": { type: "boolean" },
46
+ project: { type: "string" },
47
+ server: { type: "string" },
48
+ token: { type: "string" },
49
+ help: { type: "boolean", short: "h" },
50
+ },
51
+ });
52
+ const command = positionals[0];
53
+ if (values.help === true || command === undefined) {
54
+ process.stdout.write(usage);
55
+ return;
56
+ }
57
+ if (command !== "publish" && command !== "rollback-to-embedded") {
58
+ throw new Error(`Unknown command "${command}". Run expo-ota --help.`);
59
+ }
60
+ if (values.branch === undefined || values.branch === "") {
61
+ throw new Error("--branch is required.");
62
+ }
63
+ const platforms = [];
64
+ for (const platform of values.platform ?? []) {
65
+ if (platform !== "ios" && platform !== "android") {
66
+ throw new Error(`--platform must be ios or android, got "${platform}".`);
67
+ }
68
+ if (!platforms.includes(platform)) {
69
+ platforms.push(platform);
70
+ }
71
+ }
72
+ if (platforms.length === 0) {
73
+ platforms.push("ios", "android");
74
+ }
75
+ let rolloutPercent;
76
+ if (values.rollout !== undefined) {
77
+ rolloutPercent = Number(values.rollout);
78
+ if (!Number.isInteger(rolloutPercent) || rolloutPercent < 0 || rolloutPercent > 100) {
79
+ throw new Error(`--rollout must be a whole number between 0 and 100, got "${values.rollout}".`);
80
+ }
81
+ }
82
+ const url = values.server ?? process.env["OTA_URL"];
83
+ const token = values.token ?? process.env["OTA_PUBLISH_TOKEN"];
84
+ if (url === undefined || url === "" || token === undefined || token === "") {
85
+ throw new Error("Set OTA_URL and OTA_PUBLISH_TOKEN, or pass --server and --token. Run expo-ota --help.");
86
+ }
87
+ const server = { url: url.replace(/\/+$/, ""), token, fetch };
88
+ const projectDir = path.resolve(values.project ?? process.cwd());
89
+ const common = {
90
+ branch: values.branch,
91
+ message: values.message,
92
+ platforms,
93
+ projectDir,
94
+ server,
95
+ run,
96
+ log: (message) => process.stdout.write(`${message}\n`),
97
+ };
98
+ if (command === "rollback-to-embedded") {
99
+ await rollbackToEmbedded(common);
100
+ return;
101
+ }
102
+ await publish({
103
+ ...common,
104
+ rolloutPercent,
105
+ distDir: path.resolve(projectDir, values.dist ?? "dist"),
106
+ skipExport: values["skip-export"] === true,
107
+ noPatches: values["no-patches"] === true,
108
+ });
109
+ };
110
+ try {
111
+ await main();
112
+ }
113
+ catch (error) {
114
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
115
+ process.exitCode = 1;
116
+ }
package/dist/expo.js ADDED
@@ -0,0 +1,47 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ export const run = async (command, args, cwd) => {
5
+ const { stdout } = await execFileAsync(command, [...args], { cwd, maxBuffer: 64 * 1024 * 1024 });
6
+ return stdout;
7
+ };
8
+ export const exportProject = (run, projectDir, distDir, platforms) => run("npx", ["expo", "export", ...platforms.flatMap((platform) => ["--platform", platform]), "--output-dir", distDir], projectDir);
9
+ export const publicConfig = async (run, projectDir) => {
10
+ const stdout = await run("npx", ["expo", "config", "--type", "public", "--json"], projectDir);
11
+ // The Expo CLI prints progress lines around the JSON document.
12
+ const json = stdout.slice(stdout.indexOf("{"), stdout.lastIndexOf("}") + 1);
13
+ if (json === "") {
14
+ throw new Error("expo config --type public --json printed no JSON.");
15
+ }
16
+ return JSON.parse(json);
17
+ };
18
+ export const resolveRuntimeVersion = async (run, projectDir, platform) => {
19
+ const stdout = await run("npx", ["expo-updates", "runtimeversion:resolve", "--platform", platform], projectDir);
20
+ const lines = stdout.split("\n").filter((line) => line.trim() !== "");
21
+ const last = lines.at(-1) ?? "";
22
+ const parsed = JSON.parse(last);
23
+ if (typeof parsed.runtimeVersion !== "string") {
24
+ throw new Error(`Could not resolve the ${platform} runtime version from: ${last}`);
25
+ }
26
+ return parsed.runtimeVersion;
27
+ };
28
+ export const gitCommit = async (run, projectDir) => {
29
+ try {
30
+ return (await run("git", ["rev-parse", "HEAD"], projectDir)).trim();
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ };
36
+ // Who to credit the publish to: OTA_ACTOR wins, else the commit author.
37
+ export const actor = async (run, projectDir) => {
38
+ const fromEnv = process.env["OTA_ACTOR"];
39
+ if (fromEnv !== undefined && fromEnv !== "")
40
+ return fromEnv;
41
+ try {
42
+ return (await run("git", ["log", "-1", "--format=%ae"], projectDir)).trim() || undefined;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ };
@@ -0,0 +1,43 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { branchBundles, downloadAsset, uploadPatch } from "./server.js";
5
+ const reason = (error) => (error instanceof Error ? error.message : String(error));
6
+ // bsdiff needs about 17x the bundle in memory, so patches are built here on the
7
+ // runner and uploaded. A patch that fails to build is skipped, never fatal.
8
+ export const generatePatches = async (options) => {
9
+ const { branch, server, run, log } = options;
10
+ for (const target of options.targets) {
11
+ try {
12
+ const bundles = await branchBundles(server, branch, target.platform, target.runtimeVersion, 3);
13
+ const bases = bundles.filter((bundle) => bundle.hash !== target.hash);
14
+ if (bases.length === 0)
15
+ continue;
16
+ const dir = await mkdtemp(path.join(tmpdir(), "ota-patch-"));
17
+ try {
18
+ const targetFile = path.join(dir, "target.bundle");
19
+ await writeFile(targetFile, target.bytes);
20
+ for (const base of bases) {
21
+ try {
22
+ const baseFile = path.join(dir, `${base.hash}.bundle`);
23
+ const patchFile = path.join(dir, `${base.hash}.patch`);
24
+ await writeFile(baseFile, await downloadAsset(server, base.hash));
25
+ await run("bsdiff", [baseFile, targetFile, patchFile], dir);
26
+ const patch = await readFile(patchFile);
27
+ await uploadPatch(server, base.hash, target.hash, patch);
28
+ log(`patch ${base.hash.slice(0, 7)}..${target.hash.slice(0, 7)} ${patch.length}`);
29
+ }
30
+ catch (error) {
31
+ log(`warning: no patch from ${base.hash.slice(0, 7)}: ${reason(error)}`);
32
+ }
33
+ }
34
+ }
35
+ finally {
36
+ await rm(dir, { recursive: true, force: true });
37
+ }
38
+ }
39
+ catch (error) {
40
+ log(`warning: no patches for ${target.platform}: ${reason(error)}`);
41
+ }
42
+ }
43
+ };
@@ -0,0 +1,114 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { actor, exportProject, gitCommit, publicConfig, resolveRuntimeVersion, } from "./expo.js";
5
+ import { generatePatches } from "./patches.js";
6
+ import { missingAssets, publishGroup, uploadAsset } from "./server.js";
7
+ const contentTypes = {
8
+ png: "image/png",
9
+ jpg: "image/jpeg",
10
+ jpeg: "image/jpeg",
11
+ gif: "image/gif",
12
+ webp: "image/webp",
13
+ svg: "image/svg+xml",
14
+ ttf: "font/ttf",
15
+ otf: "font/otf",
16
+ woff: "font/woff",
17
+ woff2: "font/woff2",
18
+ json: "application/json",
19
+ mp3: "audio/mpeg",
20
+ mp4: "video/mp4",
21
+ wav: "audio/wav",
22
+ txt: "text/plain",
23
+ };
24
+ // Matches Expo's reference server: base64url sha256 addresses the bytes, md5 hex is the client-side key.
25
+ const storeAsset = async (file, contentType, fileExtension, uploads) => {
26
+ const bytes = await readFile(file);
27
+ const hash = createHash("sha256").update(bytes).digest("base64url");
28
+ uploads.set(hash, { bytes, contentType });
29
+ return { hash, key: createHash("md5").update(bytes).digest("hex"), contentType, fileExtension };
30
+ };
31
+ const uploadMissing = async (server, hashes, uploads) => {
32
+ let next = 0;
33
+ const worker = async () => {
34
+ for (let index = next++; index < hashes.length; index = next++) {
35
+ const hash = hashes[index];
36
+ const upload = uploads.get(hash);
37
+ if (upload === undefined) {
38
+ throw new Error(`The server asked for an asset this export does not contain: ${hash}`);
39
+ }
40
+ await uploadAsset(server, hash, upload.bytes, upload.contentType);
41
+ }
42
+ };
43
+ await Promise.all(Array.from({ length: Math.min(4, hashes.length) }, worker));
44
+ };
45
+ const submit = async (server, group, log) => {
46
+ const published = await publishGroup(server, group);
47
+ log(`Published group ${published.groupId}`);
48
+ for (const update of published.updates) {
49
+ log(` ${update.platform} ${update.runtimeVersion} -> ${update.id}`);
50
+ }
51
+ return published;
52
+ };
53
+ export const publish = async (options) => {
54
+ const { run, projectDir, distDir, platforms, server, log } = options;
55
+ if (!options.skipExport) {
56
+ log(`Exporting ${platforms.join(" and ")} to ${distDir}`);
57
+ await exportProject(run, projectDir, distDir, platforms);
58
+ }
59
+ const metadata = JSON.parse(await readFile(path.join(distDir, "metadata.json"), "utf8"));
60
+ const expoConfig = await publicConfig(run, projectDir);
61
+ const uploads = new Map();
62
+ const updates = {};
63
+ const targets = [];
64
+ for (const platform of platforms) {
65
+ const files = metadata.fileMetadata[platform];
66
+ if (files === undefined) {
67
+ throw new Error(`${path.join(distDir, "metadata.json")} has no ${platform} export.`);
68
+ }
69
+ const runtimeVersion = await resolveRuntimeVersion(run, projectDir, platform);
70
+ const launchAsset = await storeAsset(path.join(distDir, files.bundle), "application/javascript", ".bundle", uploads);
71
+ const assets = [];
72
+ for (const asset of files.assets) {
73
+ assets.push(await storeAsset(path.join(distDir, asset.path), contentTypes[asset.ext.toLowerCase()] ?? "application/octet-stream", `.${asset.ext}`, uploads));
74
+ }
75
+ updates[platform] = { runtimeVersion, launchAsset, assets };
76
+ targets.push({ platform, runtimeVersion, hash: launchAsset.hash, bytes: uploads.get(launchAsset.hash).bytes });
77
+ log(`${platform}: runtime ${runtimeVersion}, ${assets.length} assets`);
78
+ }
79
+ const hashes = [...uploads.keys()];
80
+ const missing = await missingAssets(server, hashes);
81
+ log(`Uploading ${missing.length} of ${hashes.length} assets`);
82
+ await uploadMissing(server, missing, uploads);
83
+ const commit = await gitCommit(run, projectDir);
84
+ const who = await actor(run, projectDir);
85
+ const published = await submit(server, {
86
+ branch: options.branch,
87
+ ...(options.message !== undefined && { message: options.message }),
88
+ ...(commit !== undefined && { gitCommit: commit }),
89
+ ...(who !== undefined && { actor: who }),
90
+ ...(options.rolloutPercent !== undefined && { rolloutPercent: options.rolloutPercent }),
91
+ expoConfig,
92
+ updates,
93
+ }, log);
94
+ if (!options.noPatches) {
95
+ await generatePatches({ branch: options.branch, targets, server, run, log });
96
+ }
97
+ return published;
98
+ };
99
+ export const rollbackToEmbedded = async (options) => {
100
+ const updates = {};
101
+ for (const platform of options.platforms) {
102
+ const runtimeVersion = await resolveRuntimeVersion(options.run, options.projectDir, platform);
103
+ updates[platform] = { runtimeVersion, rollbackToEmbedded: true };
104
+ }
105
+ const commit = await gitCommit(options.run, options.projectDir);
106
+ const who = await actor(options.run, options.projectDir);
107
+ return submit(options.server, {
108
+ branch: options.branch,
109
+ ...(options.message !== undefined && { message: options.message }),
110
+ ...(commit !== undefined && { gitCommit: commit }),
111
+ ...(who !== undefined && { actor: who }),
112
+ updates,
113
+ }, options.log);
114
+ };
package/dist/server.js ADDED
@@ -0,0 +1,56 @@
1
+ const check = async (response, what) => {
2
+ if (!response.ok) {
3
+ const body = await response.text().catch(() => "");
4
+ throw new Error(`${what} failed: ${response.status} ${response.statusText} ${body}`.trim());
5
+ }
6
+ };
7
+ export const missingAssets = async (server, hashes) => {
8
+ const response = await server.fetch(`${server.url}/publish/assets/missing`, {
9
+ method: "POST",
10
+ headers: { authorization: `Bearer ${server.token}`, "content-type": "application/json" },
11
+ body: JSON.stringify({ hashes }),
12
+ });
13
+ await check(response, "POST /publish/assets/missing");
14
+ const body = (await response.json());
15
+ return [...(body.missing ?? [])];
16
+ };
17
+ export const uploadAsset = async (server, hash, bytes, contentType) => {
18
+ const response = await server.fetch(`${server.url}/publish/assets/${hash}`, {
19
+ method: "PUT",
20
+ headers: { authorization: `Bearer ${server.token}`, "content-type": contentType },
21
+ body: bytes,
22
+ });
23
+ await check(response, `PUT /publish/assets/${hash}`);
24
+ };
25
+ export const publishGroup = async (server, group) => {
26
+ const response = await server.fetch(`${server.url}/publish/groups`, {
27
+ method: "POST",
28
+ headers: { authorization: `Bearer ${server.token}`, "content-type": "application/json" },
29
+ body: JSON.stringify(group),
30
+ });
31
+ await check(response, "POST /publish/groups");
32
+ return (await response.json());
33
+ };
34
+ export const branchBundles = async (server, branch, platform, runtimeVersion, limit) => {
35
+ const query = new URLSearchParams({ platform, runtime: runtimeVersion, limit: String(limit) });
36
+ const path = `/publish/branches/${encodeURIComponent(branch)}/bundles`;
37
+ const response = await server.fetch(`${server.url}${path}?${query}`, {
38
+ headers: { authorization: `Bearer ${server.token}` },
39
+ });
40
+ await check(response, `GET ${path}`);
41
+ const body = (await response.json());
42
+ return [...(body.bundles ?? [])];
43
+ };
44
+ export const downloadAsset = async (server, hash) => {
45
+ const response = await server.fetch(`${server.url}/assets/${hash}`);
46
+ await check(response, `GET /assets/${hash}`);
47
+ return new Uint8Array(await response.arrayBuffer());
48
+ };
49
+ export const uploadPatch = async (server, baseHash, targetHash, bytes) => {
50
+ const response = await server.fetch(`${server.url}/publish/patches/${baseHash}/${targetHash}`, {
51
+ method: "PUT",
52
+ headers: { authorization: `Bearer ${server.token}`, "content-type": "application/octet-stream" },
53
+ body: bytes,
54
+ });
55
+ await check(response, `PUT /publish/patches/${baseHash}/${targetHash}`);
56
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "open-ota",
3
+ "version": "0.1.0",
4
+ "description": "Publish Expo updates to a self-hosted Expo OTA server",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/focux/expo-ota",
9
+ "directory": "packages/cli"
10
+ },
11
+ "keywords": [
12
+ "expo",
13
+ "expo-updates",
14
+ "ota",
15
+ "cloudflare"
16
+ ],
17
+ "type": "module",
18
+ "bin": {
19
+ "expo-ota": "./dist/cli.js"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22",
32
+ "typescript": "5.9.3",
33
+ "vitest": "^4.1.11"
34
+ },
35
+ "scripts": {
36
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
37
+ "check": "tsc -p tsconfig.json",
38
+ "test": "vitest run"
39
+ }
40
+ }