diff --git a/apps/storefront/src/lib/abandonedCart.ts b/apps/storefront/src/lib/abandonedCart.ts index 298da62..8789a7e 100644 --- a/apps/storefront/src/lib/abandonedCart.ts +++ b/apps/storefront/src/lib/abandonedCart.ts @@ -4,6 +4,7 @@ import { useCart, useLanguage, type CartLineItem } from "@funky/ui"; import { authStore } from "./auth.ts"; import { mergeCartLineItemsByMaxQuantity } from "@funky/ui"; import { isBackendConfigured, restUrl } from "@funky/sdk"; +import { cacheRestRouteUnavailableFromResponse, isRestRouteAvailable } from "./restRouteAvailability.ts"; import { ABANDONED_CART_CONFIG, ABANDONED_CART_ENDPOINT, @@ -204,13 +205,19 @@ async function fetchAbandonedCartPublicConfig( backendLanguageCode: string, configuredLanguageCodes: readonly string[] = [], ): Promise { - const endpoint = restUrl("funkycommerce/v1/abandoned-carts/config"); + const route = "/funkycommerce/v1/abandoned-carts/config"; + const endpoint = restUrl(route); if (!endpoint || !isBackendConfigured) return null; const key = publicConfigCacheKey(languageCode, backendLanguageCode); - const cached = publicConfigCache.get(key); - if (cached) return cached; + if (publicConfigCache.has(key)) return publicConfigCache.get(key) ?? null; if (publicConfigInFlight.has(key)) return publicConfigInFlight.get(key)!; + if (await isRestRouteAvailable(route) !== true) { + publicConfigCache.set(key, null); + return null; + } + const pendingRequest = publicConfigInFlight.get(key); + if (pendingRequest) return pendingRequest; const url = new URL(endpoint); url.searchParams.set("language", languageCode); @@ -218,6 +225,7 @@ async function fetchAbandonedCartPublicConfig( const request = fetch(url.toString(), { cache: "no-store" }) .then(async (response) => { + await cacheRestRouteUnavailableFromResponse(route, "GET", response); if (!response.ok) return null; const raw = (await response.json().catch(() => null)) as AbandonedCartPublicConfigSource | null; const normalized = normalizeAbandonedCartPublicConfig(raw, { diff --git a/apps/storefront/src/lib/archiveSettings.test.ts b/apps/storefront/src/lib/archiveSettings.test.ts index af7b89d..9ec37e3 100644 --- a/apps/storefront/src/lib/archiveSettings.test.ts +++ b/apps/storefront/src/lib/archiveSettings.test.ts @@ -118,7 +118,6 @@ test("Store API pagination accepts empty catalogs and unavailable Woo, but not p assert.deepEqual(await fetchRestArchiveNodes("https://cms.test/products", async () => new Response(null, { status: 404 })), []); for (const response of [ - new Response("[]"), new Response("[]", { headers: { "x-wp-totalpages": "2" } }), new Response("{}", { headers: { "x-wp-totalpages": "1" } }), new Response(null, { status: 500 }), @@ -127,6 +126,22 @@ test("Store API pagination accepts empty catalogs and unavailable Woo, but not p } }); +test("Store API pagination uses page lengths when cross-origin headers are unavailable", async () => { + const calls: number[] = []; + const nodes = await fetchRestArchiveNodes("https://cms.test/products", async (input) => { + const page = Number(new URL(String(input)).searchParams.get("page")); + calls.push(page); + return new Response(JSON.stringify(page === 1 + ? Array.from({ length: ARCHIVE_BATCH_SIZE }, (_, index) => index) + : [100]), { + headers: page === 1 ? {} : { "x-wp-totalpages": "unavailable" }, + }); + }); + + assert.equal(nodes.length, ARCHIVE_BATCH_SIZE + 1); + assert.deepEqual(calls, [1, 2]); +}); + test("archive batching loads multiple cursor pages up to the requested total", async () => { const calls: { first: number; after: string | null }[] = []; const pages = [ diff --git a/apps/storefront/src/lib/archiveSettings.ts b/apps/storefront/src/lib/archiveSettings.ts index e0fadd6..b315f04 100644 --- a/apps/storefront/src/lib/archiveSettings.ts +++ b/apps/storefront/src/lib/archiveSettings.ts @@ -60,12 +60,13 @@ export async function fetchRestArchiveNodes(endpoint: string, request: ty if (!Array.isArray(payload)) throw new Error("WooCommerce Store API catalog returned a non-array payload"); const totalPagesHeader = response.headers.get("x-wp-totalpages"); const totalPages = Number(totalPagesHeader); - if (totalPagesHeader === null || !Number.isSafeInteger(totalPages) || totalPages < 0) { - throw new Error("WooCommerce Store API catalog returned invalid pagination headers"); - } nodes.push(...payload); - if (page >= totalPages) return nodes; - if (!payload.length) throw new Error("WooCommerce Store API catalog returned an incomplete page"); + if (totalPagesHeader !== null && Number.isSafeInteger(totalPages) && totalPages >= 0) { + if (page >= totalPages) return nodes; + if (!payload.length) throw new Error("WooCommerce Store API catalog returned an incomplete page"); + } else if (payload.length < ARCHIVE_BATCH_SIZE) { + return nodes; + } } throw new Error("WooCommerce Store API catalog exceeded 1,000 pagination batches"); } diff --git a/apps/storefront/src/lib/commerce.ts b/apps/storefront/src/lib/commerce.ts index 7eef463..b3423e4 100644 --- a/apps/storefront/src/lib/commerce.ts +++ b/apps/storefront/src/lib/commerce.ts @@ -42,6 +42,7 @@ import { type RawLocalizedTermTranslation, } from "./commerceTaxonomyLanguage.ts"; import { ARCHIVE_BATCH_SIZE, fetchArchiveNodesInBatches, fetchRestArchiveNodes } from "./archiveSettings.ts"; +import { isRestRouteAvailable } from "./restRouteAvailability.ts"; export type { ProductPriceBehavior, ResolvedProductPriceMode } from "./productPriceMode.ts"; export { resolveProductPriceMode } from "./productPriceMode.ts"; @@ -1245,7 +1246,9 @@ export function mapStoreApiCatalogProduct(product: StoreApiCatalogProduct): RawP } async function getStoreApiCatalogProducts(): Promise { - const endpoint = restUrl("wc/store/v1/products"); + const route = "/wc/store/v1/products"; + if (await isRestRouteAvailable(route) !== true) return []; + const endpoint = restUrl(route); if (!endpoint) return []; const products = await fetchRestArchiveNodes(endpoint); return products.map(mapStoreApiCatalogProduct); @@ -1434,6 +1437,7 @@ export async function getProductByUriOrSlug(identifier: string): Promise { + if (await isRestRouteAvailable("/wc/store/v1/products") !== true) return null; const endpoint = restUrl(`wc/store/v1/products?slug=${encodeURIComponent(slug)}&per_page=1`); if (!endpoint) return null; const response = await fetch(endpoint, { headers: { Accept: "application/json" } }); diff --git a/apps/storefront/src/lib/commerceCompatibility.test.ts b/apps/storefront/src/lib/commerceCompatibility.test.ts index 37ca856..ddd1dca 100644 --- a/apps/storefront/src/lib/commerceCompatibility.test.ts +++ b/apps/storefront/src/lib/commerceCompatibility.test.ts @@ -85,6 +85,7 @@ test("single-locale catalogs merge current Store API products with GraphQL produ assert.match(source, /const storeApiProducts = configuredLanguageCodes\.length <= 1\s*\?\s*await getOptionalStoreApiCatalogProducts\("catalog"\)/); assert.match(source, /\.\.\.storeApiProducts\.filter/); assert.match(source, /fetchRestArchiveNodes\(endpoint\)/); + assert.match(source, /isRestRouteAvailable\(route\) !== true/); assert.match(source, /Store API \$\{context\} enrichment failed; preserving authoritative GraphQL data/); }); diff --git a/apps/storefront/src/lib/protectedPages.test.ts b/apps/storefront/src/lib/protectedPages.test.ts index 143eada..3001875 100644 --- a/apps/storefront/src/lib/protectedPages.test.ts +++ b/apps/storefront/src/lib/protectedPages.test.ts @@ -13,6 +13,7 @@ test("protected pages use authenticated non-cacheable REST access and strict log assert.match(client, /X-WPGraphQL-Login-Token/); assert.match(client, /X-FunkyCommerce-Page-Proof/); assert.match(client, /cachePrivate:\s*true/); + assert.match(client, /isRestRouteAvailable\(PROTECTED_PAGE_ROUTE/); assert.match(gate, /parseStorefrontAuthRef\(`\$\{pathname\}\$\{search\}\$\{hash\}`\)/); assert.match(gate, /role="alert"/); assert.match(gate, /aria-labelledby="protected-page-title"/); diff --git a/apps/storefront/src/lib/protectedPages.ts b/apps/storefront/src/lib/protectedPages.ts index d887ea7..8bed598 100644 --- a/apps/storefront/src/lib/protectedPages.ts +++ b/apps/storefront/src/lib/protectedPages.ts @@ -1,8 +1,10 @@ import { BACKEND_ORIGIN } from "@funky/sdk"; import { authStore } from "./auth.ts"; import type { CmsPage } from "./pages.ts"; +import { isRestRouteAvailable } from "./restRouteAvailability.ts"; const PROOF_PREFIX = "funkycommerce-page-proof:"; +const PROTECTED_PAGE_ROUTE = "/funkycommerce/v1/protected-page"; export class ProtectedPageError extends Error { readonly kind: "auth-required" | "password-required" | "invalid-password"; @@ -28,7 +30,10 @@ function storedProof(uri: string): string | null { async function protectedPageRequest(uri: string, password?: string): Promise { if (!BACKEND_ORIGIN) return null; - const endpoint = new URL("/wp-json/funkycommerce/v1/protected-page", BACKEND_ORIGIN); + if (await isRestRouteAvailable(PROTECTED_PAGE_ROUTE, password === undefined ? "GET" : "POST") !== true) { + return null; + } + const endpoint = new URL(`/wp-json${PROTECTED_PAGE_ROUTE}`, BACKEND_ORIGIN); const token = authStore.getToken() || authStore.load()?.authToken; const headers: Record = { Accept: "application/json", diff --git a/apps/storefront/src/lib/restRouteAvailability.test.ts b/apps/storefront/src/lib/restRouteAvailability.test.ts new file mode 100644 index 0000000..7b3dd0d --- /dev/null +++ b/apps/storefront/src/lib/restRouteAvailability.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRestRouteAvailability, restIndexHasRoute } from "./restRouteAvailability.ts"; + +test("REST route discovery checks registered methods and reuses the REST index", async () => { + const requests: string[] = []; + const availability = createRestRouteAvailability("https://cms.test", async (input) => { + requests.push(String(input)); + return new Response(JSON.stringify({ + routes: { + "/funkycommerce/v1/protected-page": { + endpoints: [{ methods: ["GET", "POST"] }], + }, + }, + })); + }); + + assert.equal(await availability.isAvailable("/funkycommerce/v1/protected-page", "GET"), true); + assert.equal(await availability.isAvailable("/funkycommerce/v1/protected-page", "POST"), true); + assert.equal(await availability.isAvailable("/funkycommerce/v1/abandoned-carts/config"), false); + assert.equal(requests.length, 1); + assert.equal(requests[0], "https://cms.test/wp-json/"); +}); + +test("REST route discovery treats a missing or method-incompatible route as unavailable", () => { + const index = { + routes: { + "/wc/store/v1/products": { endpoints: [{ methods: ["GET"] }] }, + }, + }; + + assert.equal(restIndexHasRoute(index, "/wc/store/v1/products"), true); + assert.equal(restIndexHasRoute(index, "/wc/store/v1/products", "POST"), false); + assert.equal(restIndexHasRoute(index, "/funkycommerce/v1/protected-page"), false); +}); diff --git a/apps/storefront/src/lib/restRouteAvailability.ts b/apps/storefront/src/lib/restRouteAvailability.ts new file mode 100644 index 0000000..1bc46e7 --- /dev/null +++ b/apps/storefront/src/lib/restRouteAvailability.ts @@ -0,0 +1,80 @@ +import { BACKEND_ORIGIN } from "@funky/sdk"; + +type RestEndpoint = { + methods?: string[]; +}; + +type RestRoute = RestEndpoint & { + endpoints?: RestEndpoint[]; +}; + +type RestIndex = { + routes?: Record; +}; + +function normalizeRoute(route: string): string { + return `/${route.replace(/^\/+|\/+$/g, "")}`; +} + +export function restIndexHasRoute(index: unknown, route: string, method = "GET"): boolean { + if (!index || typeof index !== "object") return false; + const routes = (index as RestIndex).routes; + const entry = routes?.[normalizeRoute(route)]; + if (!entry) return false; + const normalizedMethod = method.toUpperCase(); + return [entry, ...(entry.endpoints || [])].some((endpoint) => + endpoint.methods?.some((registeredMethod) => registeredMethod.toUpperCase() === normalizedMethod), + ); +} + +export function createRestRouteAvailability( + backendOrigin: string | undefined, + request: typeof fetch = fetch, +) { + const unavailableRoutes = new Set(); + let restIndexRequest: Promise | undefined; + + function getRestIndex(): Promise { + if (!backendOrigin) return Promise.resolve(null); + if (!restIndexRequest) { + restIndexRequest = request(new URL("/wp-json/", backendOrigin), { + headers: { Accept: "application/json" }, + cache: "no-store", + }) + .then(async (response) => { + if (!response.ok) return null; + const payload: unknown = await response.json().catch(() => null); + return payload && typeof payload === "object" ? payload as RestIndex : null; + }) + .catch(() => null); + } + return restIndexRequest; + } + + async function isAvailable(route: string, method = "GET"): Promise { + if (!backendOrigin) return false; + const normalizedMethod = method.toUpperCase(); + const key = `${normalizedMethod}:${normalizeRoute(route)}`; + if (unavailableRoutes.has(key)) return false; + const index = await getRestIndex(); + if (!index) return null; + const available = restIndexHasRoute(index, route, normalizedMethod); + if (!available) unavailableRoutes.add(key); + return available; + } + + async function cacheUnavailableFromResponse(route: string, method: string, response: Response): Promise { + if (response.status !== 404) return; + const payload: unknown = await response.clone().json().catch(() => null); + if (payload && typeof payload === "object" && "code" in payload && payload.code === "rest_no_route") { + unavailableRoutes.add(`${method.toUpperCase()}:${normalizeRoute(route)}`); + } + } + + return { isAvailable, cacheUnavailableFromResponse }; +} + +const backendRestRoutes = createRestRouteAvailability(BACKEND_ORIGIN); + +export const isRestRouteAvailable = backendRestRoutes.isAvailable; +export const cacheRestRouteUnavailableFromResponse = backendRestRoutes.cacheUnavailableFromResponse; diff --git a/apps/storefront/src/lib/routePrefetch.test.ts b/apps/storefront/src/lib/routePrefetch.test.ts index 19ec3c1..912bc28 100644 --- a/apps/storefront/src/lib/routePrefetch.test.ts +++ b/apps/storefront/src/lib/routePrefetch.test.ts @@ -15,7 +15,7 @@ test("commerce taxonomy prefetch bypasses generic and protected page lookup", () assert.match(documentWarmupSource, /await Promise\.all\(boundedAssets\.map\(loadHydrationAsset\)\)/); assert.match(documentWarmupSource, /signal: AbortSignal\.timeout\(2_000\)/); assert.match(source, /content-node:v3:/); - assert.match(source, /\["product-category", "pro-cat", "pro-category"\]/); + assert.match(source, /\["product-category", "kategoria-produktu", "pro-cat", "pro-category"\]/); assert.match(source, /\["product-tag", "pro-tag"\]/); assert.match(source, /const taxonomy = commerceTaxonomyForPath\(pathname, languageCodes\)/); assert.match(source, /if \(taxonomy\) \{[\s\S]*getProductArchive\([\s\S]*return;/); diff --git a/apps/storefront/src/lib/routePrefetch.ts b/apps/storefront/src/lib/routePrefetch.ts index bc8cb9e..81fefef 100644 --- a/apps/storefront/src/lib/routePrefetch.ts +++ b/apps/storefront/src/lib/routePrefetch.ts @@ -14,7 +14,7 @@ function commerceTaxonomyForPath(pathname: string, languageCodes: readonly strin const routeSegments = languageCodes.some((code) => code.toLowerCase() === firstSegment) ? segments.slice(1) : segments; - if (["product-category", "pro-cat", "pro-category"].includes(routeSegments[0])) return "category"; + if (["product-category", "kategoria-produktu", "pro-cat", "pro-category"].includes(routeSegments[0])) return "category"; if (["product-tag", "pro-tag"].includes(routeSegments[0])) return "tag"; if (["brand", "product-brand"].includes(routeSegments[0])) return "brand"; if (routeSegments[0] === "shop" && routeSegments[1] === "category") return "category";