Show a proper error page instead of a blank application error, and log client-side faults
Browse files- src/app/api/client-error/route.ts +80 -0
- src/app/error.tsx +19 -0
- src/app/global-error.tsx +27 -0
- src/components/ErrorFallback.tsx +124 -0
src/app/api/client-error/route.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Sink for client-side exceptions caught by the error boundaries.
|
| 5 |
+
*
|
| 6 |
+
* Writes one line to the container log, which is readable through the Coolify API. That
|
| 7 |
+
* keeps error reporting entirely on UPB infrastructure — no third-party service, and
|
| 8 |
+
* nothing to configure on a host with no outbound internet.
|
| 9 |
+
*
|
| 10 |
+
* This endpoint is public, because the errors worth catching happen before any session
|
| 11 |
+
* exists. So it treats its input as hostile: the body is size-capped, every field is
|
| 12 |
+
* truncated, and the whole route is rate-limited, since its only effect is to write to a
|
| 13 |
+
* log that someone could otherwise flood.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
const MAX_BODY_BYTES = 16_000;
|
| 17 |
+
const MAX_MESSAGE_CHARS = 500;
|
| 18 |
+
const MAX_STACK_CHARS = 4_000;
|
| 19 |
+
const MAX_FIELD_CHARS = 300;
|
| 20 |
+
|
| 21 |
+
// Per-instance throttle. Not a security boundary — a cheap ceiling on log volume.
|
| 22 |
+
const WINDOW_MS = 60_000;
|
| 23 |
+
const MAX_REPORTS_PER_WINDOW = 60;
|
| 24 |
+
let windowStartedAt = 0;
|
| 25 |
+
let reportsThisWindow = 0;
|
| 26 |
+
|
| 27 |
+
function withinRateLimit(now: number): boolean {
|
| 28 |
+
if (now - windowStartedAt > WINDOW_MS) {
|
| 29 |
+
windowStartedAt = now;
|
| 30 |
+
reportsThisWindow = 0;
|
| 31 |
+
}
|
| 32 |
+
reportsThisWindow += 1;
|
| 33 |
+
return reportsThisWindow <= MAX_REPORTS_PER_WINDOW;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/** Coerce anything to a single-line, length-capped string. */
|
| 37 |
+
function clean(value: unknown, max: number): string {
|
| 38 |
+
if (typeof value !== 'string') return '';
|
| 39 |
+
return value.replace(/\s+/g, ' ').trim().slice(0, max);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export async function POST(request: NextRequest) {
|
| 43 |
+
// 204 on every path below: the caller is an error boundary and must never be handed
|
| 44 |
+
// another failure to deal with.
|
| 45 |
+
const ok = new NextResponse(null, { status: 204 });
|
| 46 |
+
|
| 47 |
+
if (!withinRateLimit(Date.now())) return ok;
|
| 48 |
+
|
| 49 |
+
let raw: string;
|
| 50 |
+
try {
|
| 51 |
+
raw = await request.text();
|
| 52 |
+
} catch {
|
| 53 |
+
return ok;
|
| 54 |
+
}
|
| 55 |
+
if (raw.length > MAX_BODY_BYTES) return ok;
|
| 56 |
+
|
| 57 |
+
let body: Record<string, unknown>;
|
| 58 |
+
try {
|
| 59 |
+
body = JSON.parse(raw);
|
| 60 |
+
} catch {
|
| 61 |
+
return ok;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
const message = clean(body.message, MAX_MESSAGE_CHARS);
|
| 65 |
+
const digest = clean(body.digest, MAX_FIELD_CHARS);
|
| 66 |
+
const pathname = clean(body.pathname, MAX_FIELD_CHARS);
|
| 67 |
+
const userAgent = clean(body.userAgent, MAX_FIELD_CHARS);
|
| 68 |
+
// Newlines are what make a stack readable, so keep them — but cap the length.
|
| 69 |
+
const stack =
|
| 70 |
+
typeof body.stack === 'string' ? body.stack.slice(0, MAX_STACK_CHARS) : '';
|
| 71 |
+
|
| 72 |
+
if (!message && !stack && !digest) return ok;
|
| 73 |
+
|
| 74 |
+
console.error(
|
| 75 |
+
`CLIENT ERROR: ${JSON.stringify({ message, digest, pathname, userAgent })}` +
|
| 76 |
+
(stack ? `\n${stack}` : ''),
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
return ok;
|
| 80 |
+
}
|
src/app/error.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import ErrorFallback from '@/src/components/ErrorFallback';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Route-level error boundary. Catches exceptions thrown while rendering any page below
|
| 7 |
+
* the root layout; the layout itself (nav, footer) keeps rendering around it.
|
| 8 |
+
*
|
| 9 |
+
* A failure in the root layout escapes this one — `global-error.tsx` catches those.
|
| 10 |
+
*/
|
| 11 |
+
export default function Error({
|
| 12 |
+
error,
|
| 13 |
+
reset,
|
| 14 |
+
}: {
|
| 15 |
+
error: Error & { digest?: string };
|
| 16 |
+
reset: () => void;
|
| 17 |
+
}) {
|
| 18 |
+
return <ErrorFallback error={error} reset={reset} />;
|
| 19 |
+
}
|
src/app/global-error.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import ErrorFallback from '@/src/components/ErrorFallback';
|
| 4 |
+
import './globals.css';
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Last-resort boundary for failures in the root layout itself.
|
| 8 |
+
*
|
| 9 |
+
* It replaces the whole document, so it has to supply its own `<html>` and `<body>` — the
|
| 10 |
+
* root layout is precisely what did not render. It also imports the stylesheet directly,
|
| 11 |
+
* since the layout that normally pulls it in never ran.
|
| 12 |
+
*/
|
| 13 |
+
export default function GlobalError({
|
| 14 |
+
error,
|
| 15 |
+
reset,
|
| 16 |
+
}: {
|
| 17 |
+
error: Error & { digest?: string };
|
| 18 |
+
reset: () => void;
|
| 19 |
+
}) {
|
| 20 |
+
return (
|
| 21 |
+
<html lang="en">
|
| 22 |
+
<body>
|
| 23 |
+
<ErrorFallback error={error} reset={reset} />
|
| 24 |
+
</body>
|
| 25 |
+
</html>
|
| 26 |
+
);
|
| 27 |
+
}
|
src/components/ErrorFallback.tsx
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useSyncExternalStore } from 'react';
|
| 4 |
+
|
| 5 |
+
/** The `?debug=1` flag lives in the URL, which React does not own. */
|
| 6 |
+
const subscribeToNothing = () => () => {};
|
| 7 |
+
const readDebugFlag = () =>
|
| 8 |
+
new URLSearchParams(window.location.search).get('debug') === '1';
|
| 9 |
+
// Server snapshot: the flag is unknowable until there is a `window`, and claiming `false`
|
| 10 |
+
// keeps server and client output identical until React re-renders on the client.
|
| 11 |
+
const debugFlagOnServer = () => false;
|
| 12 |
+
|
| 13 |
+
interface ErrorFallbackProps {
|
| 14 |
+
error: Error & { digest?: string };
|
| 15 |
+
reset: () => void;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* What a visitor sees when a client-side exception escapes to an error boundary.
|
| 20 |
+
*
|
| 21 |
+
* Without a boundary Next.js renders its own bare "Application error: a client-side
|
| 22 |
+
* exception has occurred" stub, which tells the visitor nothing and — more importantly —
|
| 23 |
+
* leaves us with no record that anything happened at all.
|
| 24 |
+
*
|
| 25 |
+
* Raw exception text is deliberately **not** shown by default. Next.js strips server error
|
| 26 |
+
* messages in production and substitutes `digest` precisely so internals do not leak, and
|
| 27 |
+
* this is a public site. Visitors get the digest as a short reference; the full message and
|
| 28 |
+
* stack go to the server log, and are additionally rendered inline when the URL carries
|
| 29 |
+
* `?debug=1` so the fault can be read straight off a phone or tablet with no devtools.
|
| 30 |
+
*/
|
| 31 |
+
export default function ErrorFallback({ error, reset }: ErrorFallbackProps) {
|
| 32 |
+
const showDetail = useSyncExternalStore(
|
| 33 |
+
subscribeToNothing,
|
| 34 |
+
readDebugFlag,
|
| 35 |
+
debugFlagOnServer,
|
| 36 |
+
);
|
| 37 |
+
// Reporting is idempotent per mount: React may re-render the boundary (e.g. Strict Mode
|
| 38 |
+
// double-invokes effects in development) and one fault should produce one log line.
|
| 39 |
+
const reported = useRef(false);
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
if (reported.current) return;
|
| 43 |
+
reported.current = true;
|
| 44 |
+
|
| 45 |
+
// Fire-and-forget. A failure to report must never surface to the visitor or throw from
|
| 46 |
+
// inside the boundary that is already handling an error. `keepalive` lets the request
|
| 47 |
+
// outlive the page if they navigate away immediately.
|
| 48 |
+
try {
|
| 49 |
+
void fetch('/api/client-error', {
|
| 50 |
+
method: 'POST',
|
| 51 |
+
headers: { 'Content-Type': 'application/json' },
|
| 52 |
+
body: JSON.stringify({
|
| 53 |
+
message: error.message,
|
| 54 |
+
stack: error.stack,
|
| 55 |
+
digest: error.digest,
|
| 56 |
+
pathname: window.location.pathname + window.location.search,
|
| 57 |
+
userAgent: navigator.userAgent,
|
| 58 |
+
}),
|
| 59 |
+
keepalive: true,
|
| 60 |
+
}).catch(() => {});
|
| 61 |
+
} catch {
|
| 62 |
+
// Ignore: serialisation or a blocked request must not escalate.
|
| 63 |
+
}
|
| 64 |
+
}, [error]);
|
| 65 |
+
|
| 66 |
+
return (
|
| 67 |
+
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
| 68 |
+
<main className="max-w-2xl mx-auto">
|
| 69 |
+
<div className="bg-white rounded-lg shadow-md p-6 sm:p-8 mt-8">
|
| 70 |
+
<h1 className="text-2xl font-semibold text-gray-900">Something went wrong</h1>
|
| 71 |
+
<p className="mt-3 text-gray-600">
|
| 72 |
+
This page hit an unexpected error. Trying again usually works — the rankings and
|
| 73 |
+
forecast data themselves are unaffected.
|
| 74 |
+
</p>
|
| 75 |
+
|
| 76 |
+
<div className="mt-6 flex flex-wrap gap-3">
|
| 77 |
+
<button
|
| 78 |
+
onClick={reset}
|
| 79 |
+
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
| 80 |
+
>
|
| 81 |
+
Try again
|
| 82 |
+
</button>
|
| 83 |
+
{/* Deliberately a plain anchor, not next/link. The rule exists to avoid full
|
| 84 |
+
page reloads, but a full reload is the point here: client-side navigation
|
| 85 |
+
would route within the same broken runtime the boundary just caught, and
|
| 86 |
+
`global-error` renders when the root layout itself failed. */}
|
| 87 |
+
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
|
| 88 |
+
<a
|
| 89 |
+
href="/"
|
| 90 |
+
className="px-4 py-2 rounded-md border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
|
| 91 |
+
>
|
| 92 |
+
Back to homepage
|
| 93 |
+
</a>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
{error.digest && (
|
| 97 |
+
<p className="mt-6 text-xs text-gray-500">
|
| 98 |
+
Reference: <code className="font-mono text-gray-700">{error.digest}</code>
|
| 99 |
+
<span className="block mt-1">
|
| 100 |
+
Quote this if you report the problem to us.
|
| 101 |
+
</span>
|
| 102 |
+
</p>
|
| 103 |
+
)}
|
| 104 |
+
|
| 105 |
+
{showDetail && (
|
| 106 |
+
<div className="mt-6 border-t border-gray-200 pt-4">
|
| 107 |
+
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
| 108 |
+
Debug detail
|
| 109 |
+
</p>
|
| 110 |
+
<p className="mt-2 text-sm font-medium text-gray-900 break-words">
|
| 111 |
+
{error.message || '(no message)'}
|
| 112 |
+
</p>
|
| 113 |
+
{error.stack && (
|
| 114 |
+
<pre className="mt-3 max-h-80 overflow-auto rounded bg-gray-900 p-3 text-xs leading-relaxed text-gray-100 whitespace-pre-wrap break-words">
|
| 115 |
+
{error.stack}
|
| 116 |
+
</pre>
|
| 117 |
+
)}
|
| 118 |
+
</div>
|
| 119 |
+
)}
|
| 120 |
+
</div>
|
| 121 |
+
</main>
|
| 122 |
+
</div>
|
| 123 |
+
);
|
| 124 |
+
}
|