diff --git a/apps/desktop/wrangler.jsonc b/apps/desktop/wrangler.jsonc index 8bd1786d6..bdd94461f 100644 --- a/apps/desktop/wrangler.jsonc +++ b/apps/desktop/wrangler.jsonc @@ -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", diff --git a/apps/landing/wrangler.jsonc b/apps/landing/wrangler.jsonc index c43b19a8a..48c2ae982 100644 --- a/apps/landing/wrangler.jsonc +++ b/apps/landing/wrangler.jsonc @@ -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", diff --git a/apps/ota/src/__tests__/policy.test.ts b/apps/ota/src/__tests__/policy.test.ts index dfd6a584a..7d33be0d2 100644 --- a/apps/ota/src/__tests__/policy.test.ts +++ b/apps/ota/src/__tests__/policy.test.ts @@ -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( + '', + { 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) diff --git a/apps/ota/src/__tests__/sync.test.ts b/apps/ota/src/__tests__/sync.test.ts index 504f00445..22ed81c68 100644 --- a/apps/ota/src/__tests__/sync.test.ts +++ b/apps/ota/src/__tests__/sync.test.ts @@ -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() + 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( + '', + { + 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("cookie-value", { + 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() + 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("cookie-value", { + 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() + 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", () => { diff --git a/apps/ota/src/lib/store-version.ts b/apps/ota/src/lib/store-version.ts index b61fa6ed5..81e4f01ee 100644 --- a/apps/ota/src/lib/store-version.ts +++ b/apps/ota/src/lib/store-version.ts @@ -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(url: string, context: string): Promise { 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<{ diff --git a/apps/ota/src/lib/sync.ts b/apps/ota/src/lib/sync.ts index 5f9b2ea3b..7e0485360 100644 --- a/apps/ota/src/lib/sync.ts +++ b/apps/ota/src/lib/sync.ts @@ -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`, ) } diff --git a/apps/ota/wrangler.jsonc b/apps/ota/wrangler.jsonc index f44a76306..bcb63e917 100644 --- a/apps/ota/wrangler.jsonc +++ b/apps/ota/wrangler.jsonc @@ -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": [ diff --git a/apps/ssr/wrangler.jsonc b/apps/ssr/wrangler.jsonc index 160c44cc5..97a7676b9 100644 --- a/apps/ssr/wrangler.jsonc +++ b/apps/ssr/wrangler.jsonc @@ -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",