fix(ota): harden store sync and observability

This commit is contained in:
DIYgod
2026-04-15 11:20:31 +08:00
parent 8f96f51ca4
commit 5bf010e804
8 changed files with 376 additions and 27 deletions
+9
View File
@@ -2,6 +2,15 @@
"$schema": "../../node_modules/wrangler/config-schema.json",
"name": "folo-web",
"compatibility_date": "2026-02-01",
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true,
},
"traces": {
"enabled": true,
},
},
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"assets": {
"directory": "./out/web",
+9
View File
@@ -4,6 +4,15 @@
"main": "./worker/index.js",
"compatibility_date": "2026-02-01",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true,
},
"traces": {
"enabled": true,
},
},
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"assets": {
"directory": "./dist/client",
+39
View File
@@ -143,6 +143,45 @@ describe("/policy", () => {
})
})
it("falls back to the iOS App Store page when the Apple lookup API fails", async () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url === "https://itunes.apple.com/lookup?id=6739802604") {
throw new Error("lookup unavailable")
}
if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") {
return new Response(
'<script type="application/json">{"primarySubtitle":"Version 0.4.4"}</script>',
{ status: 200, headers: { "content-type": "text/html" } },
)
}
throw new Error(`Unhandled fetch URL: ${url}`)
}),
)
const response = await fetchWorker(
"/policy?product=mobile&platform=ios&channel=production&installedBinaryVersion=0.4.1",
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
action: "prompt",
targetVersion: "0.4.4",
message: null,
})
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[ota] Apple lookup request failed for iOS store version, falling back",
expect.any(Error),
)
})
it("uses the cached iOS store version when KV is populated", async () => {
const fetchSpy = vi.fn()
vi.stubGlobal("fetch", fetchSpy)
+223
View File
@@ -1687,6 +1687,229 @@ describe("syncStoreVersions", () => {
)
expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toEqual(expect.any(String))
})
it("falls back to the iOS App Store page when the Apple lookup payload has no version", async () => {
const kvEntries = new Map<string, unknown>()
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
const url = String(input)
if (url === "https://itunes.apple.com/lookup?id=6739802604") {
return new Response(
JSON.stringify({
results: [{}],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}
if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") {
return new Response(
'<script type="application/json">{"primarySubtitle":"Version 0.5.0"}</script>',
{
status: 200,
headers: { "Content-Type": "text/html" },
},
)
}
if (url === "https://play.google.com/store/apps/details?id=is.follow&hl=en_US&gl=US") {
return new Response('[[["0.4.1"]],[[[36]]]', {
status: 200,
headers: { "Content-Type": "text/html" },
})
}
if (
url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604?platform=mac"
) {
return new Response('{"primarySubtitle":"Version 1.5.0"}', {
status: 200,
headers: { "Content-Type": "text/html" },
})
}
if (
url ===
"https://storeedgefd.dsx.mp.microsoft.com/v9.0/products/9nvfzpv0v0ht?market=US&locale=en-US&deviceFamily=Windows.Desktop"
) {
return new Response(
JSON.stringify({
Payload: {
Skus: [
{
FulfillmentData: JSON.stringify({
WuCategoryId: "wu-category-id",
PackageFamilyName:
"NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_abc123",
}),
},
],
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}
if (url === "https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx") {
const body = String(_init?.body ?? "")
if (body.includes("GetCookie")) {
return new Response("<EncryptedData>cookie-value</EncryptedData>", {
status: 200,
headers: { "Content-Type": "application/soap+xml" },
})
}
return new Response(
'&lt;PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123"&gt;',
{
status: 200,
headers: { "Content-Type": "application/soap+xml" },
},
)
}
throw new Error(`Unhandled fetch URL: ${url}`)
}),
)
await syncStoreVersions(createEnv({ kvEntries }))
expect(JSON.parse(String(kvEntries.get(KV_KEYS.storeVersion("mobile", "ios"))))).toEqual(
expect.objectContaining({
version: "0.5.0",
source: "app-store",
}),
)
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[ota] Apple lookup response did not include an iOS version, falling back",
)
})
it("logs and skips failed storefronts while persisting successful updates", async () => {
const kvEntries = new Map<string, unknown>()
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
const url = String(input)
if (url === "https://itunes.apple.com/lookup?id=6739802604") {
throw new Error("lookup unavailable")
}
if (url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604") {
return new Response("service unavailable", {
status: 503,
headers: { "Content-Type": "text/html" },
})
}
if (url === "https://play.google.com/store/apps/details?id=is.follow&hl=en_US&gl=US") {
return new Response('[[["0.4.1"]],[[[36]]]', {
status: 200,
headers: { "Content-Type": "text/html" },
})
}
if (
url === "https://apps.apple.com/us/app/folo-follow-everything/id6739802604?platform=mac"
) {
return new Response('{"primarySubtitle":"Version 1.5.0"}', {
status: 200,
headers: { "Content-Type": "text/html" },
})
}
if (
url ===
"https://storeedgefd.dsx.mp.microsoft.com/v9.0/products/9nvfzpv0v0ht?market=US&locale=en-US&deviceFamily=Windows.Desktop"
) {
return new Response(
JSON.stringify({
Payload: {
Skus: [
{
FulfillmentData: JSON.stringify({
WuCategoryId: "wu-category-id",
PackageFamilyName:
"NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_abc123",
}),
},
],
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)
}
if (url === "https://fe3.delivery.mp.microsoft.com/ClientWebService/client.asmx") {
const body = String(_init?.body ?? "")
if (body.includes("GetCookie")) {
return new Response("<EncryptedData>cookie-value</EncryptedData>", {
status: 200,
headers: { "Content-Type": "application/soap+xml" },
})
}
return new Response(
'&lt;PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123"&gt;',
{
status: 200,
headers: { "Content-Type": "application/soap+xml" },
},
)
}
throw new Error(`Unhandled fetch URL: ${url}`)
}),
)
await expect(syncStoreVersions(createEnv({ kvEntries }))).resolves.toBeUndefined()
expect(kvEntries.get(KV_KEYS.storeVersion("mobile", "ios"))).toBeUndefined()
expect(kvEntries.get(KV_KEYS.storeVersion("mobile", "android"))).toEqual(expect.any(String))
expect(kvEntries.get(KV_KEYS.storeVersion("desktop", "mas"))).toEqual(expect.any(String))
expect(kvEntries.get(KV_KEYS.storeVersion("desktop", "mss"))).toEqual(expect.any(String))
expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toEqual(expect.any(String))
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[ota] Failed to refresh store version for mobile:ios",
expect.any(Error),
)
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[ota] Store version sync completed with 1 failure(s); refreshed 3/4 providers",
)
})
it("fails when every storefront refresh fails", async () => {
const kvEntries = new Map<string, unknown>()
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("network unavailable")
}),
)
await expect(syncStoreVersions(createEnv({ kvEntries }))).rejects.toThrow(
"Failed to refresh all store versions",
)
expect(kvEntries.get(KV_KEYS.storeVersionSyncLastSuccessAt)).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalledTimes(4)
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
expect.stringContaining("Store version sync completed with"),
)
})
})
describe("internal routes", () => {
+47 -23
View File
@@ -7,7 +7,9 @@ const MICROSOFT_STORE_MARKET = "US"
const MICROSOFT_STORE_LOCALE = "en-US"
const MICROSOFT_STORE_RING = "Retail"
const PLAY_VERSION_PATTERN = /\[\[\["(\d+\.\d+\.\d+)"\]\],\[\[\[36\]\]/
const APPLE_VERSION_PATTERN = /"primarySubtitle":"Version (\d+\.\d+\.\d+)"/
const APPLE_LOOKUP_URL = `https://itunes.apple.com/lookup?id=${APPLE_APP_STORE_ID}`
const APPLE_PRIMARY_SUBTITLE_VERSION_PATTERN = /"primarySubtitle":"Version (\d+\.\d+\.\d+)"/
const APPLE_TEXT_VERSION_PATTERN = /\bVersion (\d+\.\d+\.\d+)\b/
const MICROSOFT_VERSION_PATTERN = /^\d+(?:\.\d+)+$/
export type MobileStorePlatform = "ios" | "android"
@@ -74,21 +76,31 @@ export function getDesktopStoreUrl(distribution: DesktopDistribution) {
}
async function fetchIosStoreVersion() {
const response = await fetch(`https://itunes.apple.com/lookup?id=${APPLE_APP_STORE_ID}`, {
headers: STORE_HEADERS,
})
if (!response.ok) {
throw new Error(`App Store lookup failed (${response.status})`)
try {
const response = await fetch(APPLE_LOOKUP_URL, {
headers: STORE_HEADERS,
})
if (!response.ok) {
throw new Error(`App Store lookup failed (${response.status})`)
}
const payload = (await response.json()) as {
results?: Array<{
version?: unknown
}>
}
const version = payload.results?.[0]?.version
if (typeof version === "string") {
return version
}
console.warn("[ota] Apple lookup response did not include an iOS version, falling back")
} catch (error) {
console.warn("[ota] Apple lookup request failed for iOS store version, falling back", error)
}
const payload = (await response.json()) as {
results?: Array<{
version?: unknown
}>
}
const version = payload.results?.[0]?.version
return typeof version === "string" ? version : null
return fetchAppleStorefrontVersion(STORE_URLS.ios, "iOS App Store storefront request")
}
async function fetchGooglePlayVersion() {
@@ -104,15 +116,7 @@ async function fetchGooglePlayVersion() {
}
async function fetchMacAppStoreVersion() {
const response = await fetch(STORE_URLS.mas, {
headers: STORE_HEADERS,
})
if (!response.ok) {
throw new Error(`Mac App Store storefront request failed (${response.status})`)
}
const html = await response.text()
return html.match(APPLE_VERSION_PATTERN)?.[1] ?? null
return fetchAppleStorefrontVersion(STORE_URLS.mas, "Mac App Store storefront request")
}
async function fetchMicrosoftStoreVersion() {
@@ -186,6 +190,26 @@ async function fetchJson<T>(url: string, context: string): Promise<T> {
return (await response.json()) as T
}
async function fetchAppleStorefrontVersion(url: string, context: string) {
const response = await fetch(url, {
headers: STORE_HEADERS,
})
if (!response.ok) {
throw new Error(`${context} failed (${response.status})`)
}
const html = await response.text()
return extractAppleStoreVersion(html)
}
function extractAppleStoreVersion(html: string) {
return (
html.match(APPLE_PRIMARY_SUBTITLE_VERSION_PATTERN)?.[1] ??
html.match(APPLE_TEXT_VERSION_PATTERN)?.[1] ??
null
)
}
function resolveMicrosoftFulfillmentData(payload: {
Payload?: {
Skus?: Array<{
+31 -4
View File
@@ -130,11 +130,38 @@ async function runSyncStoreVersions(env: Env) {
}),
)
const failures = results.filter((result) => result.status === "rejected")
if (failures.length > 0) {
const failures = results.flatMap((result, index) => {
const task = syncTasks[index]
if (!task || result.status === "fulfilled") {
return []
}
return [
{
task,
reason: result.reason,
},
]
})
for (const failure of failures) {
console.error(
`[ota] Failed to refresh store version for ${failure.task.product}:${failure.task.target}`,
failure.reason,
)
}
if (failures.length === syncTasks.length) {
throw new AggregateError(
failures.map((result) => (result as PromiseRejectedResult).reason),
"Failed to refresh one or more store versions",
failures.map((failure) => failure.reason),
"Failed to refresh all store versions",
)
}
if (failures.length > 0) {
console.warn(
`[ota] Store version sync completed with ${failures.length} failure(s); refreshed ${syncTasks.length - failures.length}/${syncTasks.length} providers`,
)
}
+9
View File
@@ -4,6 +4,15 @@
"main": "src/index.ts",
"compatibility_date": "2026-04-10",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true,
},
"traces": {
"enabled": true,
},
},
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"workers_dev": true,
"routes": [
+9
View File
@@ -4,6 +4,15 @@
"main": "dist/worker/worker-entry.mjs",
"compatibility_date": "2026-02-01",
"compatibility_flags": ["nodejs_compat"],
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true,
},
"traces": {
"enabled": true,
},
},
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"placement": {
"mode": "smart",