feat(ota): serve manifests assets and policy

This commit is contained in:
DIYgod 2026-04-10 19:54:15 +08:00
parent 3ef0e1b494
commit 54f5bf7c0e
6 changed files with 267 additions and 0 deletions

View File

@ -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",
)
})
})

View File

@ -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 {

View File

@ -0,0 +1,80 @@
import { buildMirroredAssetKey } from "./archive"
import type { OtaPlatform, OtaRelease } from "./schema"
type PlatformPayload = NonNullable<OtaRelease["platforms"][OtaPlatform]>
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(),
}
}

View File

@ -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,
})
})

View File

@ -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<OtaRelease>(
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"
}

View File

@ -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<StorePolicyRelease>(
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"
}