Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions apps/storefront/src/lib/abandonedCart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -204,20 +205,27 @@ async function fetchAbandonedCartPublicConfig(
backendLanguageCode: string,
configuredLanguageCodes: readonly string[] = [],
): Promise<AbandonedCartPublicConfig | null> {
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);
url.searchParams.set("locale", backendLanguageCode);

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, {
Expand Down
17 changes: 16 additions & 1 deletion apps/storefront/src/lib/archiveSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand All @@ -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<number>("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 = [
Expand Down
11 changes: 6 additions & 5 deletions apps/storefront/src/lib/archiveSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ export async function fetchRestArchiveNodes<TNode>(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");
}
Expand Down
6 changes: 5 additions & 1 deletion apps/storefront/src/lib/commerce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1245,7 +1246,9 @@ export function mapStoreApiCatalogProduct(product: StoreApiCatalogProduct): RawP
}

async function getStoreApiCatalogProducts(): Promise<RawProductCard[]> {
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<StoreApiCatalogProduct>(endpoint);
return products.map(mapStoreApiCatalogProduct);
Expand Down Expand Up @@ -1434,6 +1437,7 @@ export async function getProductByUriOrSlug(identifier: string): Promise<CmsProd
}

async function getStoreApiProductDetail(slug: string): Promise<CmsProductDetail | null> {
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" } });
Expand Down
1 change: 1 addition & 0 deletions apps/storefront/src/lib/commerceCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StoreApiCatalogProduct>\(endpoint\)/);
assert.match(source, /isRestRouteAvailable\(route\) !== true/);
assert.match(source, /Store API \$\{context\} enrichment failed; preserving authoritative GraphQL data/);
});

Expand Down
1 change: 1 addition & 0 deletions apps/storefront/src/lib/protectedPages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"/);
Expand Down
7 changes: 6 additions & 1 deletion apps/storefront/src/lib/protectedPages.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -28,7 +30,10 @@ function storedProof(uri: string): string | null {

async function protectedPageRequest(uri: string, password?: string): Promise<Response | null> {
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<string, string> = {
Accept: "application/json",
Expand Down
35 changes: 35 additions & 0 deletions apps/storefront/src/lib/restRouteAvailability.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
80 changes: 80 additions & 0 deletions apps/storefront/src/lib/restRouteAvailability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { BACKEND_ORIGIN } from "@funky/sdk";

type RestEndpoint = {
methods?: string[];
};

type RestRoute = RestEndpoint & {
endpoints?: RestEndpoint[];
};

type RestIndex = {
routes?: Record<string, RestRoute>;
};

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<string>();
let restIndexRequest: Promise<RestIndex | null> | undefined;

function getRestIndex(): Promise<RestIndex | null> {
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<boolean | null> {
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<void> {
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;
2 changes: 1 addition & 1 deletion apps/storefront/src/lib/routePrefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;/);
Expand Down
2 changes: 1 addition & 1 deletion apps/storefront/src/lib/routePrefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading