Explain a Backend URL/page-origin mismatch instead of a bare fetch error
A real Windows install had sync and backups fail everywhere with "Failed to fetch" while the server itself was fully healthy - confirmed via Invoke-WebRequest returning 200 for every endpoint the browser couldn't reach. The page was opened via a LAN IP while Settings still had the Backend URL set to 127.0.0.1 from an earlier session; browsers treat those as different origins, so the server's CORS check correctly rejected the requests, but a CORS rejection surfaces to fetch() as an unexplained generic error with no indication of the real cause. Settings now detects this mismatch on every render and shows the page's actual address with a one-click button to fix it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm
|
|||||||
|
|
||||||
No changes recorded yet.
|
No changes recorded yet.
|
||||||
|
|
||||||
|
## 1.4.2 - 2026-09-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Sync and backups could fail everywhere with an unexplained "Failed to fetch", while the server itself was completely healthy, whenever the page was opened via a different address (e.g. a LAN IP) than the one saved in Settings under "Backend URL" - browsers treat those as different origins, and the browser's own security check (CORS) silently blocks the requests without explaining why. Settings now detects this mismatch, names the page's actual address, and offers a one-click button to fix it.
|
||||||
|
|
||||||
## 1.4.1 - 2026-09-09
|
## 1.4.1 - 2026-09-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -46,8 +46,11 @@ Acceptance: users can distinguish server contact, pending edits, an active save
|
|||||||
- [x] Offer manual retry for unsaved changes without discarding local edits.
|
- [x] Offer manual retry for unsaved changes without discarding local edits.
|
||||||
- [x] Verify save failure/reconnection in a real browser.
|
- [x] Verify save failure/reconnection in a real browser.
|
||||||
- [x] Extend stale-data feedback to the personal mobile profile: last applied retrieval, 30-second stale warning, timeout and Retry without discarding loaded data.
|
- [x] Extend stale-data feedback to the personal mobile profile: last applied retrieval, 30-second stale warning, timeout and Retry without discarding loaded data.
|
||||||
|
- [x] Detect and explain a Backend URL/page-origin mismatch, the real cause behind an otherwise unexplained "Failed to fetch" on every sync and backup.
|
||||||
- [ ] Review concurrent writes, reconnect recovery and multiple controllers editing the same race.
|
- [ ] Review concurrent writes, reconnect recovery and multiple controllers editing the same race.
|
||||||
|
|
||||||
|
Fixed 2026-09-09: a real Windows install showed "Backup failed: Sync failed: Failed to fetch" and every other backend call failing the same way, while the server itself was healthy (confirmed via `Invoke-WebRequest` returning 200 for `/api/health`, `/api/state` and `/api/backups`) - only the browser's own `fetch()` calls failed. Root cause: the page was opened via the LAN IP (`http://192.168.1.100:8081`) while the persisted Settings "Backend URL" still said `http://127.0.0.1:8081` from an earlier session. Browsers treat those as different origins even on the same machine, so the server's CORS check (`server.js`, comparing the request's `Origin` header against its own `Host` header) correctly rejected it - but nothing told the user why, since a CORS-rejected response surfaces to `fetch()` as a generic "Failed to fetch" with no further detail, and a command-line tool like `Invoke-WebRequest` doesn't send an `Origin` header at all, so it never reproduced the symptom. The Settings page now compares `window.location.origin` against the configured Backend URL on every render and, when they differ, shows a plain-language warning naming the page's actual address plus a one-click "Use `<address>`" button that fixes it immediately. Verified with a headless-browser check (seeding a deliberately mismatched Backend URL, confirming the warning text names the real origin, and confirming the fix button both clears the warning and persists the corrected value to the backend) against an isolated temp SQLite instance.
|
||||||
|
|
||||||
### Implemented: decoder/socket diagnostics
|
### Implemented: decoder/socket diagnostics
|
||||||
|
|
||||||
Acceptance: each configured channel distinguishes an open socket from actual received data and successfully parsed passing messages. Inspecting diagnostics must not interrupt timing or rerender an active settings form.
|
Acceptance: each configured channel distinguishes an open socket from actual received data and successfully parsed passing messages. Inspecting diagnostics must not interrupt timing or rerender an active settings form.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rc-racecore",
|
"name": "rc-racecore",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rc-racecore",
|
"name": "rc-racecore",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rc-racecore",
|
"name": "rc-racecore",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence",
|
"description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1301,6 +1301,8 @@ const TRANSLATIONS = {
|
|||||||
"settings.pit_role_hint": "Kräver två extra fysiska decoders: en vid depå in och en vid depå ut.",
|
"settings.pit_role_hint": "Kräver två extra fysiska decoders: en vid depå in och en vid depå ut.",
|
||||||
"settings.connection_title": "Backend och anslutning",
|
"settings.connection_title": "Backend och anslutning",
|
||||||
"settings.backend_url_hint": "Webb/API på datorn som kör npm start, normalt port 8081.",
|
"settings.backend_url_hint": "Webb/API på datorn som kör npm start, normalt port 8081.",
|
||||||
|
"settings.backend_url_mismatch": "Sidan är öppnad via {origin}, men Backend URL nedan är en annan adress. Webbläsaren blockerar då synk och backup (CORS) - de måste vara identiska.",
|
||||||
|
"settings.backend_url_use_current": "Använd {origin}",
|
||||||
"settings.managed_channels_hint": "Fälten Decoder IP / host nedan är de fysiska decodrarnas adresser. Backend startar en AMMC-process per roll på unika portar: main 9000, mellantid 9001, depå in 9002 och depå ut 9003.",
|
"settings.managed_channels_hint": "Fälten Decoder IP / host nedan är de fysiska decodrarnas adresser. Backend startar en AMMC-process per roll på unika portar: main 9000, mellantid 9001, depå in 9002 och depå ut 9003.",
|
||||||
"settings.pit_entry_ws_url": "Depå in WebSocket-url",
|
"settings.pit_entry_ws_url": "Depå in WebSocket-url",
|
||||||
"settings.pit_exit_ws_url": "Depå ut WebSocket-url",
|
"settings.pit_exit_ws_url": "Depå ut WebSocket-url",
|
||||||
@@ -2713,6 +2715,8 @@ const TRANSLATIONS = {
|
|||||||
"settings.pit_role_hint": "Requires two additional physical decoders: one at pit entry and one at pit exit.",
|
"settings.pit_role_hint": "Requires two additional physical decoders: one at pit entry and one at pit exit.",
|
||||||
"settings.connection_title": "Backend and connection",
|
"settings.connection_title": "Backend and connection",
|
||||||
"settings.backend_url_hint": "Web/API on the computer running npm start, normally port 8081.",
|
"settings.backend_url_hint": "Web/API on the computer running npm start, normally port 8081.",
|
||||||
|
"settings.backend_url_mismatch": "This page was opened via {origin}, but the Backend URL below is a different address. The browser will block sync and backups (CORS) - they must be identical.",
|
||||||
|
"settings.backend_url_use_current": "Use {origin}",
|
||||||
"settings.managed_channels_hint": "Decoder IP / host fields below are physical decoder addresses. The backend starts one AMMC process per role on unique ports: main 9000, split 9001, pit entry 9002, and pit exit 9003.",
|
"settings.managed_channels_hint": "Decoder IP / host fields below are physical decoder addresses. The backend starts one AMMC process per role on unique ports: main 9000, split 9001, pit entry 9002, and pit exit 9003.",
|
||||||
"settings.pit_entry_ws_url": "Pit entry WebSocket URL",
|
"settings.pit_entry_ws_url": "Pit entry WebSocket URL",
|
||||||
"settings.pit_exit_ws_url": "Pit exit WebSocket URL",
|
"settings.pit_exit_ws_url": "Pit exit WebSocket URL",
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export function renderSettings(deps) {
|
|||||||
await action();
|
await action();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
const pageOrigin = /^https?:$/.test(window.location.protocol) ? window.location.origin : "";
|
||||||
|
const configuredBackendUrl = String(state.settings.backendUrl || getDefaultBackendUrl()).replace(/\/+$/, "");
|
||||||
|
const backendUrlMismatch = Boolean(pageOrigin && configuredBackendUrl && pageOrigin !== configuredBackendUrl);
|
||||||
const ammcStatus = ammc.status || {};
|
const ammcStatus = ammc.status || {};
|
||||||
const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : [];
|
const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : [];
|
||||||
const suggestedWsUrl = getManagedWsUrl("main");
|
const suggestedWsUrl = getManagedWsUrl("main");
|
||||||
@@ -181,6 +184,12 @@ export function renderSettings(deps) {
|
|||||||
state.settings.backendUrl || getDefaultBackendUrl()
|
state.settings.backendUrl || getDefaultBackendUrl()
|
||||||
)}" placeholder="http://127.0.0.1:8081" />
|
)}" placeholder="http://127.0.0.1:8081" />
|
||||||
</label>
|
</label>
|
||||||
|
${backendUrlMismatch ? `
|
||||||
|
<p class="settings-backend-mismatch">
|
||||||
|
${escapeHtml(t("settings.backend_url_mismatch", { origin: pageOrigin }))}
|
||||||
|
<button type="button" id="settingsUseCurrentOrigin" class="btn btn-small">${escapeHtml(t("settings.backend_url_use_current", { origin: pageOrigin }))}</button>
|
||||||
|
</p>
|
||||||
|
` : ""}
|
||||||
<label class="toggle decoder-reconnect-toggle">
|
<label class="toggle decoder-reconnect-toggle">
|
||||||
<input type="checkbox" name="autoReconnect" ${state.settings.autoReconnect ? "checked" : ""} />
|
<input type="checkbox" name="autoReconnect" ${state.settings.autoReconnect ? "checked" : ""} />
|
||||||
<span>${t("settings.auto_reconnect")}</span>
|
<span>${t("settings.auto_reconnect")}</span>
|
||||||
@@ -646,6 +655,11 @@ Erik Eriksson;654321;4WD;Associated;;</code></pre>
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder);
|
document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder);
|
||||||
|
document.getElementById("settingsUseCurrentOrigin")?.addEventListener("click", () => {
|
||||||
|
state.settings.backendUrl = pageOrigin;
|
||||||
|
saveState();
|
||||||
|
renderView();
|
||||||
|
});
|
||||||
document.querySelectorAll('[name^="raceAudio"]').forEach((control) => {
|
document.querySelectorAll('[name^="raceAudio"]').forEach((control) => {
|
||||||
control.addEventListener("change", persistRaceAudioFormValues);
|
control.addEventListener("change", persistRaceAudioFormValues);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1508,6 +1508,21 @@ body {
|
|||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-backend-mismatch {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--danger) 55%, var(--line) 45%);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.decoder-layout-options {
|
.decoder-layout-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
@@ -1712,6 +1727,13 @@ body {
|
|||||||
border-color: color-mix(in srgb, var(--line) 45%, var(--accent) 55%);
|
border-color: color-mix(in srgb, var(--line) 45%, var(--accent) 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
border-color: color-mix(in srgb, var(--accent) 72%, black 28%);
|
border-color: color-mix(in srgb, var(--accent) 72%, black 28%);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
|
|||||||
Reference in New Issue
Block a user