mmcux commited on
Commit
4bea006
·
1 Parent(s): a0a1d01

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 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
+ }