feat(ota): serve manifests assets and policy
This commit is contained in:
parent
3ef0e1b494
commit
54f5bf7c0e
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in New Issue