Skip to content
Merged
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
21 changes: 15 additions & 6 deletions packages/frontend/src/app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
import { isNetworkError, reloadWhenOnline } from '@/lib/network-error';

/**
* Global error boundary. Renders for any uncaught exception inside an App
Expand All @@ -19,16 +20,23 @@
error: Error & { digest?: string };
reset: () => void;
}) {
const offline = isNetworkError(error);

useEffect(() => {
// Reported with its stack; a no-op when Sentry is not configured.
Sentry.captureException(error, { tags: { boundary: 'segment' } });
// Reported with its stack; a no-op when Sentry is not configured. A
// dropped connection is counted as a warning and retried by itself.
Sentry.captureException(error, {
level: offline ? 'warning' : 'error',
tags: { boundary: 'segment', network: offline ? 'yes' : 'no' },
});
// Avoid leaking stack traces; just record that something tripped.
if (typeof window !== 'undefined' && error?.digest) {
console.error(`[error.tsx] uncaught error (digest=${error.digest})`);
} else {
console.error('[error.tsx] uncaught error', error);
}
}, [error]);
return offline ? reloadWhenOnline() : undefined;
}, [error, offline]);

return (
<div
Expand All @@ -54,11 +62,12 @@
}}
>
<h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>
Something went wrong
{offline ? 'Connection lost' : 'Something went wrong'}
</h1>
<p style={{ color: 'var(--muted-foreground, #6b7280)', marginBottom: 16 }}>
The page hit an unexpected error. You can retry, or go back to the
dashboard.
{offline
? 'The page could not reach the server. It reloads by itself when your connection is back.'
: 'The page hit an unexpected error. You can retry, or go back to the dashboard.'}
</p>

{error?.digest ? (
Expand Down Expand Up @@ -90,7 +99,7 @@
>
Try again
</button>
<a

Check warning on line 102 in packages/frontend/src/app/error.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, build)

Do not use an `<a>` element to navigate to `/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages
href="/"
style={{
padding: '8px 14px',
Expand Down
21 changes: 17 additions & 4 deletions packages/frontend/src/app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
import { isNetworkError, reloadWhenOnline } from '@/lib/network-error';

/**
* Last-resort boundary: renders when the root layout itself throws, where
Expand All @@ -13,9 +14,17 @@ export default function GlobalError({
}: {
error: Error & { digest?: string };
}) {
const offline = isNetworkError(error);

useEffect(() => {
Sentry.captureException(error, { tags: { boundary: 'global' } });
}, [error]);
// A dropped connection is not a bug in the page: keep counting it, as a
// warning, and reload by ourselves once the network is back.
Sentry.captureException(error, {
level: offline ? 'warning' : 'error',
tags: { boundary: 'global', network: offline ? 'yes' : 'no' },
});
return offline ? reloadWhenOnline() : undefined;
}, [error, offline]);

return (
<html lang="en">
Expand All @@ -31,9 +40,13 @@ export default function GlobalError({
}}
>
<div role="alert" style={{ maxWidth: 480 }}>
<h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Something went wrong</h1>
<h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>
{offline ? 'Connection lost' : 'Something went wrong'}
</h1>
<p style={{ color: '#6b7280', marginBottom: 16 }}>
AnythingMCP could not load this page. Reload to try again.
{offline
? 'AnythingMCP could not reach the server. The page reloads by itself when your connection is back.'
: 'AnythingMCP could not load this page. Reload to try again.'}
</p>
{error?.digest ? (
<p style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12, color: '#6b7280', marginBottom: 16 }}>
Expand Down
40 changes: 26 additions & 14 deletions packages/frontend/src/lib/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { users, server, organizations, AUTH_EXPIRED_EVENT } from './api';
import { users, server, organizations, AUTH_EXPIRED_EVENT, ApiError } from './api';
import { storage } from './storage';

interface User {
Expand Down Expand Up @@ -95,19 +95,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(savedToken);
setUser(JSON.parse(savedUser));

users.me(savedToken).then((freshUser) => {
setUser(freshUser);
storage.set('amcp_user', JSON.stringify(freshUser));
fetchOrgData(savedToken);
setIsLoading(false);
}).catch(() => {
setToken(null);
setUser(null);
storage.remove('amcp_token');
storage.remove('amcp_user');
document.cookie = 'amcp_token=; path=/; max-age=0';
setIsLoading(false);
});
const refresh = () =>
users.me(savedToken).then((freshUser) => {
setUser(freshUser);
storage.set('amcp_user', JSON.stringify(freshUser));
fetchOrgData(savedToken);
});

refresh().catch((err) => {
if (err instanceof ApiError && err.status < 500) {
// The backend answered and rejected the session.
setToken(null);
setUser(null);
storage.remove('amcp_token');
storage.remove('amcp_user');
document.cookie = 'amcp_token=; path=/; max-age=0';
return;
}
// No answer (offline, a laptop waking before its network) or a 5xx
// during a deploy says nothing about the session. Signing out here
// sent people to /login, offline, for a token that was still valid
// (ANYTHINGMCP-CLOUD-FRONTEND-7). Keep the saved session and check
// it again once the connection is back; a real 401 still signs out
// through AUTH_EXPIRED_EVENT.
window.addEventListener('online', () => void refresh().catch(() => {}), { once: true });
}).finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
Expand Down
55 changes: 55 additions & 0 deletions packages/frontend/src/lib/network-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Recognises the errors a browser raises when a request never got an answer:
* the machine went offline, woke from sleep before the network was back, or
* a script chunk could not be downloaded. Each engine words it differently.
*/
const NETWORK_ERROR_MESSAGES = [
/^Load failed$/i, // Safari
/^Failed to fetch$/i, // Chrome, Edge
/^NetworkError when attempting to fetch resource\.?$/i, // Firefox
/^Network request failed$/i,
/^Importing a module script failed\.?$/i, // Safari, dynamic import
/^Failed to fetch dynamically imported module/i, // Chrome, dynamic import
/^Loading (CSS )?chunk [\w-]+ failed/i, // webpack/turbopack chunk loader
];

export function isNetworkError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const { name, message } = error as { name?: unknown; message?: unknown };
if (name === 'ChunkLoadError') return true;
if (typeof message !== 'string') return false;
return NETWORK_ERROR_MESSAGES.some((re) => re.test(message.trim()));
}

const RELOAD_KEY = 'amcp_network_reload_at';
/** One automatic reload per this window, so a page that fails for another reason cannot loop. */
const RELOAD_COOLDOWN_MS = 30_000;

/**
* Reloads the page once the connection is back: at once if the browser says
* it is online, otherwise on the next `online` event. Returns a cleanup
* function. Does nothing if it already reloaded in the last 30 seconds.
*/
export function reloadWhenOnline(): () => void {
if (typeof window === 'undefined') return () => {};
let last = 0;
try {
last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0;
} catch {}
if (Date.now() - last < RELOAD_COOLDOWN_MS) return () => {};

const reload = () => {
try {
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
} catch {}
window.location.reload();
};

if (navigator.onLine) {
// A moment's grace: a wake from sleep often reports online before DNS works.
const timer = setTimeout(reload, 2_000);
return () => clearTimeout(timer);
}
window.addEventListener('online', reload, { once: true });
return () => window.removeEventListener('online', reload);
}
87 changes: 87 additions & 0 deletions packages/frontend/tests/e2e/network-drop.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { expect, test, type Page } from '@playwright/test';
import { isNetworkError } from '../../src/lib/network-error';

/**
* A request that gets no answer is not a rejected session.
*
* ANYTHINGMCP-CLOUD-FRONTEND-7: a Mac woke from sleep before its network was
* back, the session check (/api/users/me) failed with Safari's "Load failed",
* and the app treated that as an expired token: it deleted the saved session
* and sent the user to /login, offline, where the page broke. Only an answer
* from the backend that rejects the session may sign someone out.
*/

const USER = {
id: 'u1',
email: 'admin@example.com',
name: 'Admin',
role: 'ADMIN',
organizationId: 'o1',
emailVerified: true,
};

async function savedSession(page: Page, me: 'network-error' | 'server-error' | 'unauthorized') {
const origin = test.info().project.use.baseURL ?? 'http://localhost:3100';
await page.context().addCookies([{ name: 'amcp_token', value: 't', url: origin }]);
await page.addInitScript((user) => {
// Only on the first load: the sign-out path clears these on purpose.
if (sessionStorage.getItem('seeded')) return;
sessionStorage.setItem('seeded', '1');
localStorage.setItem('amcp_token', 't');
localStorage.setItem('amcp_user', JSON.stringify(user));
}, USER);
await page.route(/\/(api|health)\//, async (route) => {
const p = new URL(route.request().url()).pathname;
const json = (body: unknown, status = 200) =>
route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
if (p === '/health/server-info') {
return json({ deploymentMode: 'self-hosted', mcpAuthMode: 'none', hasUsers: true, registrationEnabled: true, ssoProviders: [] });
}
if (p.endsWith('/users/me/onboarding-state')) return json({ onboardingCompletedAt: new Date().toISOString() });
if (p.endsWith('/users/me')) {
if (me === 'network-error') return route.abort('internetdisconnected');
if (me === 'server-error') return json({ message: 'Bad Gateway' }, 502);
return json({ message: 'Unauthorized' }, 401);
}
if (p.endsWith('/license/status')) return json({ plan: 'business', status: 'active', features: {} });
return json([]);
});
}

test.describe('session check without an answer', () => {
test('keeps the session when /users/me gets no answer', async ({ page }) => {
await savedSession(page, 'network-error');
await page.goto('/settings');
await page.waitForLoadState('networkidle');
expect(new URL(page.url()).pathname).not.toBe('/login');
expect(await page.evaluate(() => localStorage.getItem('amcp_token'))).toBe('t');
});

test('keeps the session when the backend answers 502 during a deploy', async ({ page }) => {
await savedSession(page, 'server-error');
await page.goto('/settings');
await page.waitForLoadState('networkidle');
expect(new URL(page.url()).pathname).not.toBe('/login');
expect(await page.evaluate(() => localStorage.getItem('amcp_token'))).toBe('t');
});

test('still signs out when the backend rejects the token', async ({ page }) => {
await savedSession(page, 'unauthorized');
await page.goto('/settings');
await expect(page).toHaveURL(/\/login/);
expect(await page.evaluate(() => localStorage.getItem('amcp_token'))).toBeNull();
});
});

test.describe('isNetworkError', () => {
test('recognises what each browser says when a request gets no answer', () => {
expect(isNetworkError(new TypeError('Load failed'))).toBe(true);
expect(isNetworkError(new TypeError('Failed to fetch'))).toBe(true);
expect(isNetworkError(new TypeError('NetworkError when attempting to fetch resource.'))).toBe(true);
expect(isNetworkError(new TypeError('Importing a module script failed.'))).toBe(true);
expect(isNetworkError(Object.assign(new Error('x'), { name: 'ChunkLoadError' }))).toBe(true);
expect(isNetworkError(new TypeError("Cannot read properties of undefined (reading 'id')"))).toBe(false);
expect(isNetworkError(new Error('Load failed because of X'))).toBe(false);
expect(isNetworkError(null)).toBe(false);
});
});
Loading