diff --git a/.changeset/storefront-html-api-response.md b/.changeset/storefront-html-api-response.md new file mode 100644 index 00000000..81388b5e --- /dev/null +++ b/.changeset/storefront-html-api-response.md @@ -0,0 +1,5 @@ +--- +"@godaddy/gd-commerce-storefront": patch +--- + +Show "The store could not be reached" instead of a JSON parse error when an `/api/commerce` route answers with the app's HTML page, such as an unmounted route behind a single-page app fallback or an HTML 404 page. The failure has status 502 rather than the page's status, so an HTML 404 never clears the saved cart. Development builds log which route returned HTML and how to mount the Commerce routes. The new `apiUnavailable` message has English and French text. diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index fc8b10af..77736f77 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -79,6 +79,38 @@ report `unbound` again only after it is deliberately disconnected. The storefron the generic sample products and local sample cart; this server never creates sample Commerce records or simulates checkout. +#### A site before its store connects + +A template that shows the storefront's sample products before a store connects needs the catalog routes and an +explicit connection state. Here `COMMERCE_CONNECTION_STATE` is a host-owned setting, set to `unbound` when the site +is created and to `ready` by the host's connection flow. Read it from whatever records the connection; never derive +it from missing `GODADDY_*` variables. + +```ts +import express from 'express'; +import { createCommerceCatalogRouter, createRuntimeCommerceConfiguration } from '@godaddy/gd-commerce-server'; + +const runtime = createRuntimeCommerceConfiguration(); +const configuration = { + ...runtime, + readConnectionState() { + const state = process.env.COMMERCE_CONNECTION_STATE; + if (state === 'unbound' || state === 'connecting' || state === 'ready') return state; + throw new Error('COMMERCE_CONNECTION_STATE must be unbound, connecting, or ready.'); + }, +}; + +const app = express(); +app.use(express.json()); +// Before static files and any single-page app or SSR fallback, which would answer /api/commerce with HTML. +app.use('/api/commerce', createCommerceCatalogRouter(configuration)); +app.use(express.static('dist')); +``` + +`createCommerceCatalogRouter` exposes no checkout routes, so a site without checkout needs no `checkoutReturnUrls`. +`GET /api/commerce/config` should return `{"state":"unbound"}` as JSON. If it returns the app's HTML page, the +router is not mounted, or a fallback route runs before it. + ## Errors Every route reports failures the same way. The body is `{ "error": "", "code": "", "requestId": "" }` (order-status also includes `success: false`). The same id is passed to the logger. Bodies never contain upstream messages, configuration details, or credentials. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 2a091fff..93cbfb46 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -512,6 +512,16 @@ Cart mutations share one queue, including initial cart creation. The cart ID is Changing the server-provided cart scope resets cart state and prevents old responses from affecting the new connection. A failed configuration refresh temporarily disables commerce and also invalidates pending responses. Normal configuration loading and failures do not unmount host content. +## Troubleshooting + +**"The store could not be reached" with `returned an HTML page instead of JSON` in the console.** A request under +`/api/commerce` got the application's HTML page. The Commerce routes are not mounted on this origin, or a static +file, single-page app or SSR fallback route answers before them. Mount them at `/api/commerce` ahead of those +routes; with a separate API server in development, proxy `/api/commerce` to it. Opening `/api/commerce/config` in +the browser should return JSON. A missing route never shows sample products, even with `sampleProducts`: the server +must report `state: 'unbound'`. See [Before a store is connected](#before-a-store-is-connected) and the server +package's [example for a site before its store connects](../commerce-server/README.md#a-site-before-its-store-connects). + ## Guides - [Server API contract](docs/server-api.md) diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index ba07b6f7..d0ee6088 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -9,7 +9,7 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati - Requests use same-origin browser credentials. JSON writes set `Content-Type: application/json`. - Cart, catalog and checkout requests include `X-Commerce-Scope` from `/config`. Validate that scope against the server's current store/channel binding, especially before writes. Treat it as a stale-binding guard, not authorization. Authenticate and authorize requests independently. - Read requests use `cache: no-store`, cancellation, and a 15-second timeout. The initial cart creation/add request is also a write and is never retried automatically. -- Return a non-2xx response with `{ "error": "A useful customer-facing message" }` for failure. HTML error pages are also handled as failures. +- Return a non-2xx response with `{ "error": "A useful customer-facing message" }` for failure. HTML error pages are also handled as failures. A `text/html` response below 500, such as a single-page app fallback or an HTML 404 page answering an unmounted route, is reported as "The store could not be reached" with status 502, so it never clears a saved cart. - Only cart reads with HTTP 404 or 410 clear an expired saved cart. Network errors, 401/403/409/5xx, and other failures preserve the ID and block writes until hydration succeeds. - Resolve prices and availability on the server. The cart does not calculate shipping, taxes, or discounts; it explains that they are calculated at checkout. Browser SKU names and quantities are input, not pricing authority. Protect mutations against CSRF as appropriate for the host application's authentication. diff --git a/packages/commerce-storefront/src/api.ts b/packages/commerce-storefront/src/api.ts index 032dd14f..053cceef 100644 --- a/packages/commerce-storefront/src/api.ts +++ b/packages/commerce-storefront/src/api.ts @@ -53,11 +53,34 @@ export class ApiError extends Error { } } +const warnedHtmlPaths = new Set(); + +function warnHtmlResponse(input: RequestInfo | URL): void { + if (process.env.NODE_ENV === 'production') return; + const url: string = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const path: string = url.split(/[?#]/)[0] ?? url; + // Configuration polling repeats the request, so each path is reported once. + if (warnedHtmlPaths.has(path)) return; + warnedHtmlPaths.add(path); + console.error( + `[gd-commerce-storefront] ${path} returned an HTML page instead of JSON. Mount the commerce routes at ` + + '/api/commerce on this origin, before any static file or single-page app fallback route.', + ); +} + export const checkedFetch: typeof globalThis.fetch = async ( input: RequestInfo | URL, init?: RequestInit, ): Promise => { const response: Response = await fetch(input, init); + // An unmounted route answers with the app's HTML page: a 200 from a single-page app fallback, or a 404 page. It is + // reported as a 502, so an HTML 404 never reads as an expired cart and clears the saved cart ID. A 5xx HTML page + // comes from a gateway in front of the API and is handled below. + if (response.status < 500 && /^\s*text\/html\b/i.test(response.headers.get('Content-Type') ?? '')) { + warnHtmlResponse(input); + const text = { key: 'apiUnavailable' } as const; + throw new ApiError(formatMessage(enUS, text.key), 502, { text }); + } if (!response.ok) { const data: string = await response.text(); const parsed: unknown = ((): unknown => { diff --git a/packages/commerce-storefront/src/locales/fr-fr.ts b/packages/commerce-storefront/src/locales/fr-fr.ts index 2b1966b9..e992fa5e 100644 --- a/packages/commerce-storefront/src/locales/fr-fr.ts +++ b/packages/commerce-storefront/src/locales/fr-fr.ts @@ -26,6 +26,7 @@ export const frFR: StorefrontMessages = { requestFailed: 'La requête a échoué. Veuillez réessayer.', requestFailedStatus: 'La requête a échoué ({status}). Veuillez réessayer.', requestTimeout: 'Le chargement des informations a pris trop de temps. Veuillez réessayer.', + apiUnavailable: 'La boutique est injoignable. Veuillez réessayer.', unknownError: 'Un problème est survenu. Veuillez réessayer.', storageUnavailable: 'Votre navigateur ne peut pas enregistrer le panier. Gardez cette page ouverte pendant vos achats.', diff --git a/packages/commerce-storefront/src/messages.ts b/packages/commerce-storefront/src/messages.ts index 1c30b3d7..22e8cb7c 100644 --- a/packages/commerce-storefront/src/messages.ts +++ b/packages/commerce-storefront/src/messages.ts @@ -17,6 +17,7 @@ const englishText = { requestFailed: 'Commerce request failed. Please retry.', requestFailedStatus: 'Commerce request failed ({status}). Please retry.', requestTimeout: 'Commerce information took too long to load. Please retry.', + apiUnavailable: 'The store could not be reached. Please retry.', unknownError: 'Something went wrong. Please retry.', storageUnavailable: 'Your browser cannot save the cart. Keep this page open while shopping.', invalidAddItem: 'Select a product variant and a positive quantity.', diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 495507c2..4f9f09ba 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event'; import { type ReactNode, useState } from 'react'; import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router'; import { describe, expect, it, vi } from 'vitest'; -import { ApiError, CartIdStorage, money, request } from './api'; +import { ApiError, CartIdStorage, checkedFetch, money, request } from './api'; import { AddToCartButton, CartButton } from './cart'; import { addToCart, type CartOrder } from './cart-model'; import { Catalog } from './catalog'; @@ -207,6 +207,19 @@ describe('shared cart', () => { expect(view.context().error).toBeNull(); }, ); + it('keeps a saved cart when the cart route answers with an HTML 404 page', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + localStorage.setItem(storageKey, 'existing'); + mockApi((path) => + path.endsWith('/config') + ? response(configuration) + : new Response('', { status: 404, headers: { 'Content-Type': 'text/html' } }), + ); + const view = mount(); + await connected(view); + expect(localStorage.getItem(storageKey)).toBe('existing'); + expect(view.context().error).toBe('The store could not be reached. Please retry.'); + }); it('starts a new cart after the server reports that a completed order is no longer a draft', async () => { localStorage.setItem(storageKey, 'completed-cart'); const api = mockApi((path, init) => { @@ -537,6 +550,48 @@ describe('storage and transport', () => { expect(error.status).toBe(502); expect(error.cause).toBeInstanceOf(SyntaxError); }); + it('reports an HTML page served for an API route as an unavailable store', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn( + async (input: RequestInfo | URL) => + new Response('', { + // A single-page app fallback answers with a 200; a server without one answers with a 404 page. + status: String(input).includes('retry') ? 404 : 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }), + ), + ); + const failures = await Promise.all([ + request('/config').catch((cause: unknown) => cause), + request('/config?retry=1').catch((cause: unknown) => cause), + addToCart(null, { skuId: 'mug', name: 'Mug', quantity: 1 }, checkedFetch).catch( + (cause: unknown) => cause, + ), + ]); + for (const failure of failures) { + expect(failure).toBeInstanceOf(ApiError); + expect(failure).toHaveProperty('message', 'The store could not be reached. Please retry.'); + expect(failure).toHaveProperty('text', { key: 'apiUnavailable' }); + expect(failure).toHaveProperty('status', 502); + } + // One hint per path, naming the route to mount. + expect(error).toHaveBeenCalledTimes(2); + expect(error.mock.calls[0]?.[0]).toContain('/api/commerce/config returned an HTML page instead of JSON'); + expect(error.mock.calls[1]?.[0]).toContain('/api/commerce/cart returned an HTML page'); + }); + it('shows the unavailable-store message in the catalog when the API route serves HTML', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { headers: { 'Content-Type': 'text/html' } })), + ); + mount(); + expect(await screen.findByText('The store could not be reached. Please retry.')).toBeVisible(); + expect(screen.queryByText(/Unexpected token/)).not.toBeInTheDocument(); + expect(screen.queryByText('Product 1')).not.toBeInTheDocument(); + }); it('formats currency minor units according to the currency exponent', () => { expect(money(1234, 'USD')).toBe('$12.34'); expect(money(1234, 'JPY')).toBe('¥1,234');