fix(mints): native app fetched /api/mints from the local shell -> HTML/JSON error

On the native app the shell is served from https://localhost, so the relative
/api/mints/known hit the local asset server and returned index.html, surfacing as
'Unexpected token < ... not valid JSON' on /mints.

- Native now fetches https://skate-spots.org/api/mints/known via CapacitorHttp
  (bundled core plugin, no WebView CORS), with a plain-fetch fallback.
- Web/dev unchanged (same-origin relative path via the Netlify redirect / Vite
  proxy).
- HTML or unparseable bodies now become a friendly 'unavailable' message instead
  of a raw JSON parse error.
This commit is contained in:
SkateSpots 2026-09-27 17:25:16 +07:00
parent 4e4c50ab71
commit 6a442c394f
2 changed files with 88 additions and 18 deletions

View file

@ -248,6 +248,15 @@ session with **no other memory of this project**. So:
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
https://mintradar.org/api/mints/:splat 200`), dev via `server.proxy` in
`vite.config.ts`. Data is unofficial/rate-limited; adding a mint is native-only.
⚠️ **Relative `/api/*` paths don't work in the native app** (fixed 2026-09-27):
the shell is served from `https://localhost`, so `/api/mints/known` hits the
LOCAL asset server and returns `index.html` → `Unexpected token '<' … not valid
JSON`. `useMintDirectory` now uses `https://skate-spots.org/api/mints/known`
via **CapacitorHttp** on native (core plugin, no CORS) with a plain-fetch
fallback, and turns an HTML body into a friendly message. Same pattern already
used by `sasquatchBrain.ts` / `useSasquatchAiStatus` (`Capacitor.isNativePlatform()
? 'https://skate-spots.org/api/…' : '/api/…'`) — follow it for any new `/api`
call, and never let a raw JSON parse error reach the UI.
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
retries, then one full reload per session, so a chunk-load failure self-heals
instead of showing the error boundary.

View file

@ -1,12 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { CapacitorHttp } from '@capacitor/core';
import { isNativePlatform } from '@/lib/platform';
/**
* A mint from the MintRadar community directory (https://mintradar.org).
*
* Fetched **same-origin** at `/api/mints/*` — MintRadar has no CORS, so prod
* proxies it via a Netlify redirect (see `public/_redirects`) and dev via the
* Vite proxy in `vite.config.ts`. The data is unofficial, rate-limited and may
* change without notice — treat it as a hint, not gospel.
* MintRadar sends no CORS headers, so on the WEB we fetch it **same-origin** at
* `/api/mints/*` — Netlify proxies it in prod (see `public/_redirects`) and the
* Vite proxy does in dev.
*
* On the NATIVE app the shell is served from `https://localhost`, so a relative
* `/api/...` path hits the **local asset server** and gets `index.html` back —
* which blew up as `Unexpected token '<' … is not valid JSON` on the Mints page.
* So native goes to the real site with Capacitor's native HTTP, which also
* bypasses WebView CORS (MintRadar has none).
*
* The data is unofficial, rate-limited and may change without notice — treat it
* as a hint, not gospel.
*/
export interface MintDirectoryEntry {
url: string;
@ -23,24 +33,75 @@ export interface MintDirectoryEntry {
lastOnlineAt?: string;
}
const SITE_ORIGIN = 'https://skate-spots.org';
const DIRECTORY_PATH = '/api/mints/known';
const UNAVAILABLE = 'The mint directory is unavailable right now.';
/** Parse a JSON body, turning an HTML page into a friendly error. */
function parseDirectory(text: string): unknown {
const trimmed = text.trim();
if (trimmed.startsWith('<')) {
// An HTML page (SPA fallback or an upstream error) — never surface a raw
// "Unexpected token '<'" to the user.
throw new Error(UNAVAILABLE);
}
try {
return JSON.parse(trimmed);
} catch {
throw new Error('Unexpected mint directory response.');
}
}
function normalize(data: unknown): MintDirectoryEntry[] {
if (!Array.isArray(data)) throw new Error('Unexpected mint directory response.');
return (data as MintDirectoryEntry[]).filter(
(m) => m && typeof m.url === 'string' && m.url.length > 0,
);
}
async function loadDirectory(signal: AbortSignal): Promise<MintDirectoryEntry[]> {
// Web / dev: relative URL → same-origin proxy.
if (!isNativePlatform()) {
const res = await fetch(DIRECTORY_PATH, {
signal,
headers: { accept: 'application/json' },
});
if (!res.ok) throw new Error(`The mint directory is unavailable right now (HTTP ${res.status}).`);
return normalize(parseDirectory(await res.text()));
}
const absolute = `${SITE_ORIGIN}${DIRECTORY_PATH}`;
// Native: Capacitor's HTTP plugin (no CORS restrictions).
try {
const res = await CapacitorHttp.request({
url: absolute,
method: 'GET',
headers: { accept: 'application/json' },
connectTimeout: 15000,
readTimeout: 15000,
});
if (res.status >= 200 && res.status < 300) {
// CapacitorHttp parses JSON for us; a non-JSON body arrives as a string.
const raw: unknown = typeof res.data === 'string' ? parseDirectory(res.data) : res.data;
return normalize(raw);
}
console.warn('[mints] native HTTP returned', res.status);
} catch (e) {
console.warn('[mints] native HTTP failed:', e);
}
// Fallback: a plain fetch to the absolute URL (works on CORS-free WebViews).
const res = await fetch(absolute, { signal, headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`The mint directory is unavailable right now (HTTP ${res.status}).`);
return normalize(parseDirectory(await res.text()));
}
export function useMintDirectory() {
return useQuery<MintDirectoryEntry[]>({
queryKey: ['mint-directory'],
staleTime: 10 * 60 * 1000,
retry: 1,
queryFn: async (c) => {
const res = await fetch('/api/mints/known', {
signal: AbortSignal.any([c.signal, AbortSignal.timeout(15000)]),
headers: { accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`The mint directory is unavailable right now (HTTP ${res.status}).`);
}
const data = (await res.json()) as unknown;
if (!Array.isArray(data)) throw new Error('Unexpected mint directory response.');
return (data as MintDirectoryEntry[]).filter(
(m) => m && typeof m.url === 'string' && m.url.length > 0,
);
},
queryFn: (c) => loadDirectory(AbortSignal.any([c.signal, AbortSignal.timeout(20000)])),
});
}