Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/storefront-html-api-response.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions packages/commerce-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<customer-facing message>", "code": "<code>", "requestId": "<id>" }` (order-status also includes `success: false`). The same id is passed to the logger. Bodies never contain upstream messages, configuration details, or credentials.
Expand Down
10 changes: 10 additions & 0 deletions packages/commerce-storefront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/commerce-storefront/docs/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions packages/commerce-storefront/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,32 @@ export class ApiError extends Error {
}
}

const warnedHtmlPaths = new Set<string>();

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<Response> => {
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 => {
Expand Down
1 change: 1 addition & 0 deletions packages/commerce-storefront/src/locales/fr-fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
1 change: 1 addition & 0 deletions packages/commerce-storefront/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
42 changes: 41 additions & 1 deletion packages/commerce-storefront/src/storefront.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('<!doctype html><html></html>', {
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('<!doctype html>', { headers: { 'Content-Type': 'text/html' } })),
);
mount(<Catalog sampleProducts />);
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');
Expand Down
Loading