From 0c7f6cc4b42f29068ec52af573ebb3404c270b80 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Wed, 4 Nov 2026 13:15:32 -0800 Subject: [PATCH 1/2] Explain when a Commerce route answers with the app's HTML page When a host never mounts /api/commerce, its single-page app or SSR fallback answers with index.html and a 200, and the storefront showed a JSON parse error: Unexpected token '<', " --- .changeset/storefront-html-api-response.md | 5 +++ packages/commerce-server/README.md | 32 ++++++++++++++ packages/commerce-storefront/README.md | 10 +++++ .../commerce-storefront/docs/server-api.md | 2 +- packages/commerce-storefront/src/api.ts | 21 ++++++++++ .../commerce-storefront/src/locales/fr-fr.ts | 1 + packages/commerce-storefront/src/messages.ts | 1 + .../src/storefront.test.tsx | 42 ++++++++++++++++++- 8 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 .changeset/storefront-html-api-response.md diff --git a/.changeset/storefront-html-api-response.md b/.changeset/storefront-html-api-response.md new file mode 100644 index 000000000..ae1f68a21 --- /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. 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 fc8b10af7..77736f779 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 2a091fffb..93cbfb464 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 ba07b6f72..84358eae6 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, and so is a successful `text/html` response, such as a single-page app fallback answering an unmounted route. - 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 032dd14f1..7c2c6571e 100644 --- a/packages/commerce-storefront/src/api.ts +++ b/packages/commerce-storefront/src/api.ts @@ -53,11 +53,32 @@ 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 behind a single-page app fallback answers with the app's HTML page and a 200. + if (response.ok && /^\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), response.status, { 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 2b1966b92..e992fa5e1 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 1c30b3d78..22e8cb7c8 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 495507c27..20d868434 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'; @@ -537,6 +537,46 @@ 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 () => + new Response('', { + status: 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' }); + } + // 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'); From 21cc7d57f30587ee38214097a9c0f12023f27d7b Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Thu, 5 Nov 2026 08:58:42 -0800 Subject: [PATCH 2/2] Treat an HTML page of any status below 500 as an unreachable store An HTML 404 from a host without a single-page app fallback read as an expired cart and cleared the saved cart ID. Report every HTML response below 500 as apiUnavailable with status 502, so cart hydration keeps the ID and host code branching on status sees a failure. Co-Authored-By: Claude Opus 5.5 --- .changeset/storefront-html-api-response.md | 2 +- .../commerce-storefront/docs/server-api.md | 2 +- packages/commerce-storefront/src/api.ts | 8 +++++--- .../src/storefront.test.tsx | 19 +++++++++++++++++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/.changeset/storefront-html-api-response.md b/.changeset/storefront-html-api-response.md index ae1f68a21..81388b5e8 100644 --- a/.changeset/storefront-html-api-response.md +++ b/.changeset/storefront-html-api-response.md @@ -2,4 +2,4 @@ "@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. Development builds log which route returned HTML and how to mount the Commerce routes. The new `apiUnavailable` message has English and French text. +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-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 84358eae6..d0ee60886 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, and so is a successful `text/html` response, such as a single-page app fallback answering an unmounted route. +- 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 7c2c6571e..053cceef8 100644 --- a/packages/commerce-storefront/src/api.ts +++ b/packages/commerce-storefront/src/api.ts @@ -73,11 +73,13 @@ export const checkedFetch: typeof globalThis.fetch = async ( init?: RequestInit, ): Promise => { const response: Response = await fetch(input, init); - // An unmounted route behind a single-page app fallback answers with the app's HTML page and a 200. - if (response.ok && /^\s*text\/html\b/i.test(response.headers.get('Content-Type') ?? '')) { + // 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), response.status, { text }); + throw new ApiError(formatMessage(enUS, text.key), 502, { text }); } if (!response.ok) { const data: string = await response.text(); diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 20d868434..4f9f09baf 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -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) => { @@ -542,9 +555,10 @@ describe('storage and transport', () => { vi.stubGlobal( 'fetch', vi.fn( - async () => + async (input: RequestInfo | URL) => new Response('', { - status: 200, + // 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' }, }), ), @@ -560,6 +574,7 @@ describe('storage and transport', () => { 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);