From 54f5bf7c0e9664dbc2a76d94b238bbfafe9e9918 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Fri, 10 Apr 2026 19:54:15 +0800 Subject: [PATCH] feat(ota): serve manifests assets and policy --- apps/ota/src/__tests__/manifest.test.ts | 53 ++++++++++++++++ apps/ota/src/index.ts | 6 ++ apps/ota/src/lib/manifest.ts | 80 +++++++++++++++++++++++++ apps/ota/src/routes/assets.ts | 24 ++++++++ apps/ota/src/routes/manifest.ts | 69 +++++++++++++++++++++ apps/ota/src/routes/policy.ts | 35 +++++++++++ 6 files changed, 267 insertions(+) create mode 100644 apps/ota/src/__tests__/manifest.test.ts create mode 100644 apps/ota/src/lib/manifest.ts create mode 100644 apps/ota/src/routes/assets.ts create mode 100644 apps/ota/src/routes/manifest.ts create mode 100644 apps/ota/src/routes/policy.ts diff --git a/apps/ota/src/__tests__/manifest.test.ts b/apps/ota/src/__tests__/manifest.test.ts new file mode 100644 index 000000000..6681bd4de --- /dev/null +++ b/apps/ota/src/__tests__/manifest.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest" + +import { buildManifest } from "../lib/manifest" +import type { OtaRelease } from "../lib/schema" + +describe("buildManifest", () => { + it("rewrites launch asset and asset URLs to Worker-served asset routes", () => { + const manifest = buildManifest( + { + schemaVersion: 1, + product: "mobile", + channel: "production", + releaseVersion: "0.4.2", + releaseKind: "ota", + runtimeVersion: "0.4.1", + publishedAt: "2026-04-10T12:00:00Z", + git: { tag: "mobile/v0.4.2", commit: "abcdef1234567890" }, + policy: { + storeRequired: false, + minSupportedBinaryVersion: "0.4.1", + message: null, + }, + platforms: { + ios: { + launchAsset: { + path: "bundles/ios-main.js", + sha256: "a".repeat(64), + contentType: "application/javascript", + }, + assets: [ + { + path: "assets/one.png", + sha256: "b".repeat(64), + contentType: "image/png", + }, + ], + }, + }, + } satisfies OtaRelease, + { + origin: "https://ota.folo.is", + platform: "ios", + }, + ) + + expect(manifest.launchAsset.url).toBe( + "https://ota.folo.is/assets/mobile/production/0.4.1/0.4.2/ios/bundles/ios-main.js", + ) + expect(manifest.assets[0]?.url).toBe( + "https://ota.folo.is/assets/mobile/production/0.4.1/0.4.2/ios/assets/one.png", + ) + }) +}) diff --git a/apps/ota/src/index.ts b/apps/ota/src/index.ts index bfccfbe62..efd9bf781 100644 --- a/apps/ota/src/index.ts +++ b/apps/ota/src/index.ts @@ -1,9 +1,15 @@ import { Hono } from "hono" import type { Env } from "./env" +import { assetsRoute } from "./routes/assets" +import { manifestRoute } from "./routes/manifest" +import { policyRoute } from "./routes/policy" const app = new Hono<{ Bindings: Env }>() +app.route("/", manifestRoute) +app.route("/", assetsRoute) +app.route("/", policyRoute) app.get("/internal/health", (c) => c.json({ ok: true })) export default { diff --git a/apps/ota/src/lib/manifest.ts b/apps/ota/src/lib/manifest.ts new file mode 100644 index 000000000..0327b211f --- /dev/null +++ b/apps/ota/src/lib/manifest.ts @@ -0,0 +1,80 @@ +import { buildMirroredAssetKey } from "./archive" +import type { OtaPlatform, OtaRelease } from "./schema" + +type PlatformPayload = NonNullable +type PlatformAsset = PlatformPayload["launchAsset"] | PlatformPayload["assets"][number] + +export interface ManifestAsset { + key: string + contentType: string + url: string +} + +export interface OtaManifest { + id: string + createdAt: string + runtimeVersion: string + launchAsset: ManifestAsset + assets: ManifestAsset[] + metadata: { + channel: string + releaseVersion: string + } + extra: { + product: OtaRelease["product"] + } +} + +export function buildManifest( + release: OtaRelease, + input: { + origin: string + platform: OtaPlatform + }, +): OtaManifest { + const platformPayload = release.platforms[input.platform] + + if (!platformPayload) { + throw new Error( + `Release ${release.releaseVersion} does not include a ${input.platform} payload`, + ) + } + + return { + id: `${release.product}-${release.channel}-${release.releaseVersion}-${input.platform}`, + createdAt: release.publishedAt, + runtimeVersion: release.runtimeVersion, + launchAsset: toManifestAsset( + release, + input.origin, + input.platform, + platformPayload.launchAsset, + ), + assets: platformPayload.assets.map((asset) => + toManifestAsset(release, input.origin, input.platform, asset), + ), + metadata: { + channel: release.channel, + releaseVersion: release.releaseVersion, + }, + extra: { + product: release.product, + }, + } +} + +function toManifestAsset( + release: OtaRelease, + origin: string, + platform: OtaPlatform, + asset: PlatformAsset, +): ManifestAsset { + return { + key: asset.path, + contentType: asset.contentType, + url: new URL( + `/assets/${buildMirroredAssetKey(release, platform, asset.path)}`, + origin, + ).toString(), + } +} diff --git a/apps/ota/src/routes/assets.ts b/apps/ota/src/routes/assets.ts new file mode 100644 index 000000000..3f6ac6104 --- /dev/null +++ b/apps/ota/src/routes/assets.ts @@ -0,0 +1,24 @@ +import { Hono } from "hono" + +import type { Env } from "../env" +import { IMMUTABLE_ASSET_CACHE_CONTROL } from "../lib/r2" + +export const assetsRoute = new Hono<{ Bindings: Env }>() + +assetsRoute.get("/assets/*", async (c) => { + const key = c.req.path.replace(/^\/assets\/+/, "") + const object = await c.env.OTA_BUCKET.get(key) + + if (!object) { + return c.text("Not found", 404) + } + + const headers = new Headers() + object.writeHttpMetadata(headers) + headers.set("cache-control", IMMUTABLE_ASSET_CACHE_CONTROL) + + return new Response(object.body, { + status: 200, + headers, + }) +}) diff --git a/apps/ota/src/routes/manifest.ts b/apps/ota/src/routes/manifest.ts new file mode 100644 index 000000000..b8ccbb974 --- /dev/null +++ b/apps/ota/src/routes/manifest.ts @@ -0,0 +1,69 @@ +import { Hono } from "hono" + +import type { Env } from "../env" +import { KV_KEYS } from "../lib/constants" +import { getLatestReleasePointer } from "../lib/kv" +import { buildManifest } from "../lib/manifest" +import type { OtaPlatform, OtaRelease } from "../lib/schema" + +const OTA_PLATFORMS: readonly OtaPlatform[] = ["ios", "android", "macos", "windows", "linux"] +const OTA_PRODUCTS = ["mobile", "desktop"] as const + +export const manifestRoute = new Hono<{ Bindings: Env }>() + +manifestRoute.get("/manifest", async (c) => { + const platform = parsePlatform(c.req.header("expo-platform")) + const runtimeVersion = c.req.header("expo-runtime-version") ?? "" + const channel = c.req.header("expo-channel-name") ?? "production" + const product = parseProduct(c.req.query("product")) + + if (!platform) { + return c.body(null, 204) + } + + const pointer = await getLatestReleasePointer(c.env.OTA_KV, { + product, + channel, + runtimeVersion, + platform, + }) + + if (!pointer) { + return c.body(null, 204) + } + + const release = await c.env.OTA_KV.get( + KV_KEYS.release(product, pointer.releaseVersion), + "json", + ) + + if (!release || !release.platforms[platform]) { + return c.body(null, 204) + } + + return new Response( + JSON.stringify(buildManifest(release, { origin: new URL(c.req.url).origin, platform })), + { + status: 200, + headers: { + "cache-control": "private, max-age=0", + "content-type": "application/expo+json; charset=utf-8", + "expo-protocol-version": "1", + "expo-sfv-version": "0", + vary: "expo-platform, expo-runtime-version, expo-channel-name", + }, + }, + ) +}) + +function parsePlatform(value: string | undefined): OtaPlatform | null { + if (!value) { + return "ios" + } + + return OTA_PLATFORMS.find((platform) => platform === value) ?? null +} + +function parseProduct(value: string | undefined): OtaRelease["product"] { + return OTA_PRODUCTS.find((product) => product === value) ?? "mobile" +} diff --git a/apps/ota/src/routes/policy.ts b/apps/ota/src/routes/policy.ts new file mode 100644 index 000000000..7fada9f6a --- /dev/null +++ b/apps/ota/src/routes/policy.ts @@ -0,0 +1,35 @@ +import { Hono } from "hono" + +import type { Env } from "../env" +import { KV_KEYS } from "../lib/constants" +import { evaluateStorePolicy } from "../lib/policy" +import type { OtaRelease } from "../lib/schema" + +type StorePolicyRelease = Pick< + OtaRelease, + "releaseVersion" | "releaseKind" | "runtimeVersion" | "policy" +> + +const OTA_PRODUCTS = ["mobile", "desktop"] as const + +export const policyRoute = new Hono<{ Bindings: Env }>() + +policyRoute.get("/policy", async (c) => { + const product = parseProduct(c.req.query("product")) + const channel = c.req.query("channel") ?? "production" + const installedBinaryVersion = c.req.query("installedBinaryVersion") ?? "0.0.0" + const latestStoreRelease = await c.env.OTA_KV.get( + KV_KEYS.policy(product, channel), + "json", + ) + + return c.json( + evaluateStorePolicy(latestStoreRelease, { + installedBinaryVersion, + }), + ) +}) + +function parseProduct(value: string | undefined): OtaRelease["product"] { + return OTA_PRODUCTS.find((product) => product === value) ?? "mobile" +}