| 1 | import { 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 | |
| 20 | export const DEVELOPER_COOKIE = "g1t_dev"; |
| 21 | |
| 22 | /** Whether the cookie's value turns developer mode on. */ |
| 23 | export 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. */ |
| 28 | export 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 | */ |
| 38 | export 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. |
| 45 | let chosen: boolean | null = null; |
| 46 | const listeners = new Set<() => void>(); |
| 47 | |
| 48 | function subscribe(listener: () => void) { |
| 49 | listeners.add(listener); |
| 50 | return () => listeners.delete(listener); |
| 51 | } |
| 52 | |
| 53 | function 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 | */ |
| 64 | export 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. */ |
| 73 | export 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`. */ |
| 80 | export 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. */ |
| 97 | export 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 | |
| 115 | function 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 | */ |
| 128 | export 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 | } |