mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-16 20:51:43 +08:00
fix(ota): harden store sync and observability
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
'<PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123">',
|
||||
{
|
||||
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(
|
||||
'<PackageMoniker="NaturalSelectionLabs.Follow-Yourfavoritesinoneinbo_1.5.0.0_x64__abc123">',
|
||||
{
|
||||
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", () => {
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user