Skip to content
145 linesCodeBlameRaw
1import { useSyncExternalStore } from "react";
2
3/**
4 * Developer mode: what a person turns on (Settings, Account) so that when
5 * something breaks they can say what. It is kept per browser in a cookie
6 * (`g1t_dev`), as Appearance is, so the server knows it from the first
7 * byte. While it is on:
8 *
9 * - a page that fails shows the error itself, its stack, where it happened
10 * and the build, with a button that copies it all to paste into a report
11 * (`debugReport`);
12 * - a signed-in request is answered by React Router in its development
13 * mode (workers/app.ts), which sends the server's own error instead of
14 * "Unexpected Server Error". That mode differs from production only in
15 * that, and in hooks only the Vite dev server sets. It is for the person's
16 * own requests alone, and g1t's code is open, so the error tells them
17 * nothing of anyone else's.
18 */
19
20export const DEVELOPER_COOKIE = "g1t_dev";
21
22/** Whether the cookie's value turns developer mode on. */
23export function readDeveloperMode(value: string | null | undefined): boolean {
24 return value === "1";
25}
26
27/** The Set-Cookie value that turns it on for a year, or off. */
28export function developerCookie(on: boolean, secure: boolean): string {
29 const lasts = on ? "Max-Age=31536000" : "Max-Age=0";
30 return `${DEVELOPER_COOKIE}=${on ? "1" : ""}; Path=/; ${lasts}; SameSite=Lax${secure ? "; Secure" : ""}`;
31}
32
33/**
34 * Whether a request is answered with the server's own errors: developer
35 * mode on, and signed in (a session cookie, or a token, which the site
36 * checks before anything is read).
37 */
38export function wantsServerErrors(request: Request): boolean {
39 const cookies = request.headers.get("cookie") ?? "";
40 const on = new RegExp(`(?:^|;\\s*)${DEVELOPER_COOKIE}=1(?:;|$)`).test(cookies);
41 return on && (/(?:^|;\s*)g1t_session=/.test(cookies) || request.headers.has("authorization"));
42}
43
44// Switched in this tab since the page loaded; null until it is.
45let chosen: boolean | null = null;
46const listeners = new Set<() => void>();
47
48function subscribe(listener: () => void) {
49 listeners.add(listener);
50 return () => listeners.delete(listener);
51}
52
53function fromCookie(): boolean {
54 const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${DEVELOPER_COOKIE}=([^;]*)`));
55 return readDeveloperMode(match?.[1]);
56}
57
58/**
59 * Whether developer mode is on: as switched in this tab, else as this
60 * browser's cookie says. `drawn` is what the server drew the page with (the
61 * root loader's), used while the page hydrates; an error page whose root
62 * data is gone draws without it, then shows the details once in the browser.
63 */
64export function useDeveloperMode(drawn?: boolean): boolean {
65 return useSyncExternalStore(
66 subscribe,
67 () => chosen ?? fromCookie(),
68 () => drawn ?? false,
69 );
70}
71
72/** Turn developer mode on or off in this browser, now and for the pages the server draws next. */
73export function setDeveloperMode(on: boolean) {
74 chosen = on;
75 if (typeof document !== "undefined") document.cookie = developerCookie(on, window.location.protocol === "https:");
76 for (const listener of listeners) listener();
77}
78
79/** What a failed page knows about itself, for `debugReport`. */
80export type DebugFacts = {
81 url: string;
82 /** When it was seen in the browser; null while the server draws the page. */
83 at: Date | null;
84 error: unknown;
85 /** The routes the page was drawn from, outermost first. */
86 routes: string[];
87 /** Which build of the site: its stylesheet's hashed name. */
88 build: string;
89 userAgent: string;
90 /** Inside the desktop app: its version. */
91 desktop?: string | null;
92 /** Whether the error came from a document the server drew, or a navigation in the browser. */
93 navigation: "document" | "client";
94};
95
96/** The error's parts, whatever was thrown. */
97export function describeError(error: unknown): { kind: string; status?: number; message: string; stack?: string; data?: string } {
98 if (error && typeof error === "object" && "status" in error && "data" in error && typeof (error as { status: unknown }).status === "number") {
99 const response = error as { status: number; statusText?: string; data: unknown; error?: unknown };
100 const inner = response.error instanceof Error ? response.error : null;
101 return {
102 kind: "Response",
103 status: response.status,
104 message: [response.status, response.statusText].filter(Boolean).join(" "),
105 data: typeof response.data === "string" ? response.data : safeJson(response.data),
106 stack: inner?.stack,
107 };
108 }
109 if (error instanceof Error) {
110 return { kind: error.name || "Error", message: error.message, stack: error.stack };
111 }
112 return { kind: typeof error, message: typeof error === "string" ? error : safeJson(error) };
113}
114
115function safeJson(value: unknown): string {
116 try {
117 return JSON.stringify(value, null, 2) ?? String(value);
118 } catch {
119 return String(value);
120 }
121}
122
123/**
124 * Everything about a failed page, as text to paste into a report: what
125 * failed, where, when and on which build. Never anything of the person's
126 * beyond the address they were at.
127 */
128export function debugReport(facts: DebugFacts): string {
129 const error = describeError(facts.error);
130 const lines = [
131 `g1t error report`,
132 ``,
133 `URL: ${facts.url}`,
134 `Time: ${facts.at?.toISOString() ?? "(not yet in the browser)"}`,
135 `Loaded: ${facts.navigation === "document" ? "a page drawn by the server" : "a navigation in the browser"}`,
136 `Build: ${facts.build}`,
137 `Routes: ${facts.routes.join(" > ") || "(none)"}`,
138 `Browser: ${facts.userAgent}`,
139 ];
140 if (facts.desktop) lines.push(`Desktop app: ${facts.desktop}`);
141 lines.push(``, `${error.kind}${error.status ? ` ${error.status}` : ""}: ${error.message}`);
142 if (error.data) lines.push(``, `Data:`, error.data);
143 if (error.stack) lines.push(``, `Stack:`, error.stack);
144 return lines.join("\n");
145}