@tristan2828/ui-foundation 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/gateway/client-errors.d.ts +4 -0
- package/dist/api/gateway/client-errors.js +14 -0
- package/dist/api/gateway/errors.d.ts +1 -4
- package/dist/api/gateway/errors.js +12 -0
- package/dist/api/schema.d.ts +64 -0
- package/dist/components/app/app-shell.js +4 -1
- package/dist/components/app/foundation-providers.d.ts +9 -1
- package/dist/components/app/foundation-providers.js +5 -2
- package/dist/components/app/route-error-boundary.js +11 -0
- package/dist/error-reporting/error-reporting-context.d.ts +6 -0
- package/dist/error-reporting/error-reporting-context.js +16 -0
- package/dist/error-reporting/error-reporting-provider.d.ts +14 -0
- package/dist/error-reporting/error-reporting-provider.js +28 -0
- package/dist/error-reporting/reporter.d.ts +30 -0
- package/dist/error-reporting/reporter.js +126 -0
- package/dist/index.d.ts +1 -0
- package/dist/mocks/client-errors.d.ts +7 -0
- package/dist/mocks/client-errors.js +24 -0
- package/dist/mocks/index.d.ts +1 -0
- package/dist/mocks/index.js +1 -0
- package/openapi/foundation.yaml +45 -1
- package/package.json +1 -1
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { components } from "../schema.js";
|
|
2
|
+
export type ClientErrorReport = components["schemas"]["ClientErrorReport"];
|
|
3
|
+
/** Resolves whatever happens: a report that can't be sent is dropped. */
|
|
4
|
+
export declare function reportClientError(report: ClientErrorReport): Promise<void>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { safeFetch } from "./errors.js";
|
|
2
|
+
/** Resolves whatever happens: a report that can't be sent is dropped. */
|
|
3
|
+
export async function reportClientError(report) {
|
|
4
|
+
try {
|
|
5
|
+
await safeFetch("/client-errors", {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "content-type": "application/json" },
|
|
8
|
+
body: JSON.stringify(report),
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
// Network down: nothing to do, and nothing to report it to.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { type ApiResponse } from "../transport/index.js";
|
|
2
2
|
import type { AppError } from "../contracts.js";
|
|
3
3
|
export declare function safeFetch(path: string, init?: RequestInit): Promise<ApiResponse>;
|
|
4
|
-
|
|
5
|
-
* Pass the response's `headers` so a 429 carries its Retry-After
|
|
6
|
-
* (`retryAfterSeconds`); without them it's still `rate_limited`.
|
|
7
|
-
*/
|
|
4
|
+
export declare function httpStatusOf(error: AppError): number | undefined;
|
|
8
5
|
export declare function toAppError(status: number, body: unknown, headers?: Headers): AppError;
|
|
9
6
|
export declare function networkError(): AppError;
|
|
@@ -15,7 +15,19 @@ export async function safeFetch(path, init) {
|
|
|
15
15
|
* Pass the response's `headers` so a 429 carries its Retry-After
|
|
16
16
|
* (`retryAfterSeconds`); without them it's still `rate_limited`.
|
|
17
17
|
*/
|
|
18
|
+
// The HTTP status each AppError came from, kept beside it rather than on
|
|
19
|
+
// it, so AppError's shape (and every test comparing one) doesn't change.
|
|
20
|
+
// Error reporting reads it (src/error-reporting/reporter.ts).
|
|
21
|
+
const STATUS = new WeakMap();
|
|
22
|
+
export function httpStatusOf(error) {
|
|
23
|
+
return STATUS.get(error);
|
|
24
|
+
}
|
|
18
25
|
export function toAppError(status, body, headers) {
|
|
26
|
+
const error = translate(status, body, headers);
|
|
27
|
+
STATUS.set(error, status);
|
|
28
|
+
return error;
|
|
29
|
+
}
|
|
30
|
+
function translate(status, body, headers) {
|
|
19
31
|
if (status === 422) {
|
|
20
32
|
// A rule the server enforces (a cap, a status the change needs) is
|
|
21
33
|
// often refused with FastAPI's HTTPException shape: a sentence, not a
|
package/dist/api/schema.d.ts
CHANGED
|
@@ -111,6 +111,26 @@ export interface paths {
|
|
|
111
111
|
patch?: never;
|
|
112
112
|
trace?: never;
|
|
113
113
|
};
|
|
114
|
+
"/client-errors": {
|
|
115
|
+
parameters: {
|
|
116
|
+
query?: never;
|
|
117
|
+
header?: never;
|
|
118
|
+
path?: never;
|
|
119
|
+
cookie?: never;
|
|
120
|
+
};
|
|
121
|
+
get?: never;
|
|
122
|
+
put?: never;
|
|
123
|
+
/**
|
|
124
|
+
* A failure in the browser, for the backend to forward to error reporting
|
|
125
|
+
* @description Sent by the package when an app turns on FoundationProviders' errorReporting. Needs a session, so the public can't post into it. The backend may drop a report (a duplicate, too many) and still answer 204. The user is the session's; the report never carries request or response bodies, cookies, headers, form values or query strings.
|
|
126
|
+
*/
|
|
127
|
+
post: operations["reportClientError"];
|
|
128
|
+
delete?: never;
|
|
129
|
+
options?: never;
|
|
130
|
+
head?: never;
|
|
131
|
+
patch?: never;
|
|
132
|
+
trace?: never;
|
|
133
|
+
};
|
|
114
134
|
}
|
|
115
135
|
export type webhooks = Record<string, never>;
|
|
116
136
|
export interface components {
|
|
@@ -146,6 +166,25 @@ export interface components {
|
|
|
146
166
|
name: components["schemas"]["User"]["name"];
|
|
147
167
|
password: components["schemas"]["LoginRequest"]["password"];
|
|
148
168
|
};
|
|
169
|
+
ClientErrorReport: {
|
|
170
|
+
/**
|
|
171
|
+
* @description Where the package caught it.
|
|
172
|
+
* @enum {string}
|
|
173
|
+
*/
|
|
174
|
+
source: "route" | "window" | "unhandledrejection" | "query" | "mutation";
|
|
175
|
+
/** @description The error's type (TypeError, ServerError). */
|
|
176
|
+
type: string;
|
|
177
|
+
message: string;
|
|
178
|
+
stack?: string;
|
|
179
|
+
/** @description The route pattern (/items/:id), never the URL. */
|
|
180
|
+
route?: string;
|
|
181
|
+
/** @description The HTTP method of a failed request. */
|
|
182
|
+
method?: string;
|
|
183
|
+
/** @description The HTTP status of a failed request. */
|
|
184
|
+
status?: number;
|
|
185
|
+
/** @description The frontend build (its commit). */
|
|
186
|
+
release?: string;
|
|
187
|
+
};
|
|
149
188
|
/** @description FastAPI's default 422 shape (RequestValidationError). */
|
|
150
189
|
ValidationErrorBody: {
|
|
151
190
|
detail: {
|
|
@@ -378,4 +417,29 @@ export interface operations {
|
|
|
378
417
|
500: components["responses"]["ServerError"];
|
|
379
418
|
};
|
|
380
419
|
};
|
|
420
|
+
reportClientError: {
|
|
421
|
+
parameters: {
|
|
422
|
+
query?: never;
|
|
423
|
+
header?: never;
|
|
424
|
+
path?: never;
|
|
425
|
+
cookie?: never;
|
|
426
|
+
};
|
|
427
|
+
requestBody: {
|
|
428
|
+
content: {
|
|
429
|
+
"application/json": components["schemas"]["ClientErrorReport"];
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
responses: {
|
|
433
|
+
/** @description Received (forwarded, or dropped as a duplicate). */
|
|
434
|
+
204: {
|
|
435
|
+
headers: {
|
|
436
|
+
[name: string]: unknown;
|
|
437
|
+
};
|
|
438
|
+
content?: never;
|
|
439
|
+
};
|
|
440
|
+
401: components["responses"]["UnauthorizedError"];
|
|
441
|
+
422: components["responses"]["ValidationError"];
|
|
442
|
+
500: components["responses"]["ServerError"];
|
|
443
|
+
};
|
|
444
|
+
};
|
|
381
445
|
}
|
|
@@ -6,6 +6,8 @@ import { Link, Navigate, Outlet, useLocation } from 'react-router';
|
|
|
6
6
|
import { useAuth } from '../../auth/use-auth.js';
|
|
7
7
|
import { activeNavTo, navGroups } from './app-shell-nav.js';
|
|
8
8
|
import { ErrorState } from './error-state.js';
|
|
9
|
+
import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
|
|
10
|
+
import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
|
|
9
11
|
import { Button } from '../ui/button.js';
|
|
10
12
|
import { Skeleton } from '../ui/skeleton.js';
|
|
11
13
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
|
|
@@ -41,6 +43,7 @@ function AppShellSkeleton() {
|
|
|
41
43
|
export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }) {
|
|
42
44
|
const { user, status, error, retry, logout } = useAuth();
|
|
43
45
|
const location = useLocation();
|
|
46
|
+
const reporter = useErrorReporter();
|
|
44
47
|
// Set before logout starts, so the redirect below knows this was a
|
|
45
48
|
// deliberate logout. It can't be a follow-up navigate() after logout
|
|
46
49
|
// resolves: that runs before React re-renders, and the <Navigate> this
|
|
@@ -66,7 +69,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }
|
|
|
66
69
|
return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
|
|
67
70
|
}
|
|
68
71
|
const activeTo = activeNavTo(nav, location.pathname);
|
|
69
|
-
return (_jsxs(TooltipProvider, { children: [_jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: group.items.map(({ to, label, icon: Icon }) => {
|
|
72
|
+
return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: group.items.map(({ to, label, icon: Icon }) => {
|
|
70
73
|
// A plain Link, not NavLink: NavLink marks every
|
|
71
74
|
// matching entry current, and only the most specific
|
|
72
75
|
// one should be (activeNavTo).
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { type QueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { type ReactNode } from 'react';
|
|
3
|
-
|
|
3
|
+
import { type ErrorReportingOptions } from '../../error-reporting/reporter.js';
|
|
4
|
+
export declare function FoundationProviders({ mockMode, queryClient, errorReporting, children, }: {
|
|
4
5
|
/** Whether the API is served by MSW — the app's own IS_MOCK_MODE. */
|
|
5
6
|
mockMode: boolean;
|
|
6
7
|
/** Defaults to createQueryClient(); pass one to share it with code outside React. */
|
|
7
8
|
queryClient?: QueryClient;
|
|
9
|
+
/**
|
|
10
|
+
* Off by default. On, crashes, uncaught errors and server failures are
|
|
11
|
+
* posted to the app's own POST /api/client-errors (while signed in),
|
|
12
|
+
* which forwards them; see docs/deploy.md "Error reporting" for what a
|
|
13
|
+
* report holds and never holds. `{ release }` names the build.
|
|
14
|
+
*/
|
|
15
|
+
errorReporting?: boolean | ErrorReportingOptions;
|
|
8
16
|
children: ReactNode;
|
|
9
17
|
}): import("react").JSX.Element;
|
|
@@ -5,13 +5,16 @@ import { createQueryClient } from '../../api/query-client.js';
|
|
|
5
5
|
import { AuthProvider } from '../../auth/use-auth.js';
|
|
6
6
|
import { MockModeBanner } from './mock-mode-banner.js';
|
|
7
7
|
import { ThemeProvider } from '../theme-provider.js';
|
|
8
|
+
import { ErrorReportingProvider } from '../../error-reporting/error-reporting-provider.js';
|
|
9
|
+
import { resolveErrorReporting } from '../../error-reporting/reporter.js';
|
|
8
10
|
// Everything an app's root needs around its router, in the order that
|
|
9
11
|
// works: the theme outermost, the mock-mode banner above the router so it
|
|
10
12
|
// shows on every route (/login included), and AuthProvider inside
|
|
11
13
|
// QueryClientProvider because it queries /auth/me through TanStack Query.
|
|
12
14
|
// An app's DataEnvironmentBanner goes first among the children, before its
|
|
13
15
|
// router: it queries too, and shows on every route like the mock banner.
|
|
14
|
-
export function FoundationProviders({ mockMode, queryClient, children, }) {
|
|
16
|
+
export function FoundationProviders({ mockMode, queryClient, errorReporting, children, }) {
|
|
15
17
|
const [client] = useState(() => queryClient ?? createQueryClient());
|
|
16
|
-
|
|
18
|
+
const reporting = resolveErrorReporting(errorReporting);
|
|
19
|
+
return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: reporting ? _jsx(ErrorReportingProvider, { options: reporting, children: children }) : children }) })] }));
|
|
17
20
|
}
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { TriangleAlertIcon } from 'lucide-react';
|
|
3
|
+
import { useEffect } from 'react';
|
|
3
4
|
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
|
5
|
+
import { useErrorReporter, useRoutePattern } from '../../error-reporting/error-reporting-context.js';
|
|
4
6
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
5
7
|
export function RouteErrorBoundary() {
|
|
6
8
|
const error = useRouteError();
|
|
9
|
+
const reporter = useErrorReporter();
|
|
10
|
+
const route = useRoutePattern();
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
// A crash, or a loader's 5xx; never a 404 (no such page) or other 4xx.
|
|
13
|
+
if (!reporter || (isRouteErrorResponse(error) && error.status < 500))
|
|
14
|
+
return;
|
|
15
|
+
reporter.setRoute(route);
|
|
16
|
+
reporter.report('route', error, isRouteErrorResponse(error) ? { status: error.status } : undefined);
|
|
17
|
+
}, [reporter, error, route]);
|
|
7
18
|
const message = isRouteErrorResponse(error)
|
|
8
19
|
? `${error.status} ${error.statusText}`
|
|
9
20
|
: error instanceof Error
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ErrorReporter } from './reporter.js';
|
|
2
|
+
export declare const ErrorReportingContext: import("react").Context<ErrorReporter | null>;
|
|
3
|
+
/** The reporter, or null when the app hasn't turned reporting on. */
|
|
4
|
+
export declare function useErrorReporter(): ErrorReporter | null;
|
|
5
|
+
/** The current route's pattern (`/widgets/:id`), from a data router's matches. */
|
|
6
|
+
export declare function useRoutePattern(): string | null;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { useMatches } from 'react-router';
|
|
3
|
+
import { routePattern } from './reporter.js';
|
|
4
|
+
// Null unless FoundationProviders' errorReporting is on
|
|
5
|
+
// (./error-reporting-provider.tsx), so every use of it does nothing then.
|
|
6
|
+
export const ErrorReportingContext = createContext(null);
|
|
7
|
+
/** The reporter, or null when the app hasn't turned reporting on. */
|
|
8
|
+
export function useErrorReporter() {
|
|
9
|
+
return useContext(ErrorReportingContext);
|
|
10
|
+
}
|
|
11
|
+
/** The current route's pattern (`/widgets/:id`), from a data router's matches. */
|
|
12
|
+
export function useRoutePattern() {
|
|
13
|
+
const matches = useMatches();
|
|
14
|
+
const last = matches[matches.length - 1];
|
|
15
|
+
return last ? routePattern(last.pathname, last.params) : null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type ErrorReporter, type ErrorReportingOptions } from './reporter.js';
|
|
3
|
+
export declare function ErrorReportingProvider({ options, children }: {
|
|
4
|
+
options: ErrorReportingOptions;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
7
|
+
/**
|
|
8
|
+
* Keeps the reporter's route current, for reports that don't know theirs.
|
|
9
|
+
* AppShell renders it only while reporting is on: the router's matches
|
|
10
|
+
* need a data router (createBrowserRouter), which reporting then needs too.
|
|
11
|
+
*/
|
|
12
|
+
export declare function ReportRoutePattern({ reporter }: {
|
|
13
|
+
reporter: ErrorReporter;
|
|
14
|
+
}): null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// The React side of error reporting (./reporter.ts): FoundationProviders
|
|
3
|
+
// mounts this inside AuthProvider when `errorReporting` is on. Off, there's
|
|
4
|
+
// no provider, the context is null, and nothing here runs.
|
|
5
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { reportClientError } from '../api/gateway/client-errors.js';
|
|
8
|
+
import { useAuth } from '../auth/use-auth.js';
|
|
9
|
+
import { ErrorReportingContext, useRoutePattern } from './error-reporting-context.js';
|
|
10
|
+
import { createErrorReporter } from './reporter.js';
|
|
11
|
+
export function ErrorReportingProvider({ options, children }) {
|
|
12
|
+
const queryClient = useQueryClient();
|
|
13
|
+
const { status } = useAuth();
|
|
14
|
+
const [reporter] = useState(() => createErrorReporter({ options, send: (report) => void reportClientError(report) }));
|
|
15
|
+
useEffect(() => reporter.setSignedIn(status === 'authenticated'), [reporter, status]);
|
|
16
|
+
useEffect(() => reporter.install(queryClient, window), [reporter, queryClient]);
|
|
17
|
+
return _jsx(ErrorReportingContext.Provider, { value: reporter, children: children });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Keeps the reporter's route current, for reports that don't know theirs.
|
|
21
|
+
* AppShell renders it only while reporting is on: the router's matches
|
|
22
|
+
* need a data router (createBrowserRouter), which reporting then needs too.
|
|
23
|
+
*/
|
|
24
|
+
export function ReportRoutePattern({ reporter }) {
|
|
25
|
+
const pattern = useRoutePattern();
|
|
26
|
+
useEffect(() => reporter.setRoute(pattern), [reporter, pattern]);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { ClientErrorReport } from "../api/gateway/client-errors.js";
|
|
3
|
+
export type ErrorReportingOptions = {
|
|
4
|
+
/** The frontend build a report names, e.g. the commit (`import.meta.env.VITE_RELEASE`). */
|
|
5
|
+
release?: string;
|
|
6
|
+
};
|
|
7
|
+
export type ReportSource = ClientErrorReport["source"];
|
|
8
|
+
/** FoundationProviders' prop, as options, or null when reporting is off. */
|
|
9
|
+
export declare function resolveErrorReporting(option: boolean | ErrorReportingOptions | undefined): ErrorReportingOptions | null;
|
|
10
|
+
export declare function scrub(text: string, limit: number): string;
|
|
11
|
+
/** `/widgets/7/edit` with `{ id: '7' }` → `/widgets/:id/edit`; a splat → `*`. */
|
|
12
|
+
export declare function routePattern(pathname: string, params: Readonly<Record<string, string | undefined>>): string;
|
|
13
|
+
export type ErrorReporter = {
|
|
14
|
+
/** Reports `error` from `source`, unless it's a duplicate or signed out. */
|
|
15
|
+
report: (source: ReportSource, error: unknown, request?: {
|
|
16
|
+
method?: string;
|
|
17
|
+
status?: number;
|
|
18
|
+
}) => void;
|
|
19
|
+
/** The screen's route pattern, for reports that don't know theirs. */
|
|
20
|
+
setRoute: (pattern: string | null) => void;
|
|
21
|
+
/** Whether there's a session: the endpoint refuses without one, so nothing is sent until then. */
|
|
22
|
+
setSignedIn: (signedIn: boolean) => void;
|
|
23
|
+
/** Listens on the window and the query client; returns the undo. */
|
|
24
|
+
install: (queryClient: QueryClient, target: Pick<EventTarget, "addEventListener" | "removeEventListener">) => () => void;
|
|
25
|
+
};
|
|
26
|
+
export declare function createErrorReporter({ options, send, now, }: {
|
|
27
|
+
options: ErrorReportingOptions;
|
|
28
|
+
send: (report: ClientErrorReport) => void;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}): ErrorReporter;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { httpStatusOf } from "../api/gateway/errors.js";
|
|
2
|
+
const DEDUPE_MS = 60_000;
|
|
3
|
+
const MAX_MESSAGE = 2000;
|
|
4
|
+
const MAX_STACK = 16000;
|
|
5
|
+
const REPORTED_KINDS = new Set(["server"]);
|
|
6
|
+
/** FoundationProviders' prop, as options, or null when reporting is off. */
|
|
7
|
+
export function resolveErrorReporting(option) {
|
|
8
|
+
if (!option)
|
|
9
|
+
return null;
|
|
10
|
+
return option === true ? {} : option;
|
|
11
|
+
}
|
|
12
|
+
// A query string or fragment in a URL or path, up to a stack frame's
|
|
13
|
+
// trailing :line:col (the backend's scrub, in app/error_reporting.py, is
|
|
14
|
+
// the same rule).
|
|
15
|
+
const URL_QUERY = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#"'()]+|(?<![\w/])\/[^\s?#"'()]*)[?#][^\s"'()]*?(?=(?::\d+){1,2}(?=[\s"'()]|$)|[\s"'()]|$)/gi;
|
|
16
|
+
export function scrub(text, limit) {
|
|
17
|
+
const cut = text.replace(URL_QUERY, "$1");
|
|
18
|
+
return cut.length <= limit ? cut : `${cut.slice(0, limit - 1)}…`;
|
|
19
|
+
}
|
|
20
|
+
/** `/widgets/7/edit` with `{ id: '7' }` → `/widgets/:id/edit`; a splat → `*`. */
|
|
21
|
+
export function routePattern(pathname, params) {
|
|
22
|
+
const named = Object.entries(params).filter(([name, value]) => name !== "*" && value);
|
|
23
|
+
const splat = params["*"];
|
|
24
|
+
let path = pathname;
|
|
25
|
+
if (splat && path.endsWith(`/${splat}`))
|
|
26
|
+
path = `${path.slice(0, -splat.length)}*`;
|
|
27
|
+
return path
|
|
28
|
+
.split("/")
|
|
29
|
+
.map((segment) => {
|
|
30
|
+
const match = named.find(([, value]) => value === decodeSegment(segment));
|
|
31
|
+
return match ? `:${match[0]}` : segment;
|
|
32
|
+
})
|
|
33
|
+
.join("/");
|
|
34
|
+
}
|
|
35
|
+
function decodeSegment(segment) {
|
|
36
|
+
try {
|
|
37
|
+
return decodeURIComponent(segment);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return segment;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isAppError(value) {
|
|
44
|
+
return typeof value === "object" && value !== null && "kind" in value && "message" in value;
|
|
45
|
+
}
|
|
46
|
+
function describe(error) {
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
return { type: error.name || "Error", message: error.message, stack: error.stack };
|
|
49
|
+
}
|
|
50
|
+
if (isAppError(error))
|
|
51
|
+
return { type: "ServerError", message: error.message };
|
|
52
|
+
return { type: "Error", message: typeof error === "string" ? error : "A value that isn't an Error was thrown." };
|
|
53
|
+
}
|
|
54
|
+
export function createErrorReporter({ options, send, now = Date.now, }) {
|
|
55
|
+
const lastSent = new Map();
|
|
56
|
+
let route = null;
|
|
57
|
+
let signedIn = false;
|
|
58
|
+
const report = (source, error, request = {}) => {
|
|
59
|
+
if (!signedIn)
|
|
60
|
+
return;
|
|
61
|
+
const described = describe(error);
|
|
62
|
+
const message = scrub(described.message, MAX_MESSAGE);
|
|
63
|
+
const signature = [source, described.type, message, route ?? ""].join("\u001f");
|
|
64
|
+
const at = now();
|
|
65
|
+
for (const [key, sentAt] of lastSent)
|
|
66
|
+
if (at - sentAt >= DEDUPE_MS)
|
|
67
|
+
lastSent.delete(key);
|
|
68
|
+
if (lastSent.has(signature))
|
|
69
|
+
return;
|
|
70
|
+
lastSent.set(signature, at);
|
|
71
|
+
send({
|
|
72
|
+
source,
|
|
73
|
+
type: described.type.slice(0, 200),
|
|
74
|
+
message,
|
|
75
|
+
...(described.stack ? { stack: scrub(described.stack, MAX_STACK) } : {}),
|
|
76
|
+
...(route ? { route } : {}),
|
|
77
|
+
...(request.method ? { method: request.method } : {}),
|
|
78
|
+
...(request.status ? { status: request.status } : {}),
|
|
79
|
+
...(options.release ? { release: options.release.slice(0, 100) } : {}),
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const reportAppError = (source, error, method) => {
|
|
83
|
+
if (!isAppError(error) || !REPORTED_KINDS.has(error.kind))
|
|
84
|
+
return;
|
|
85
|
+
report(source, error, { method, status: httpStatusOf(error) });
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
report,
|
|
89
|
+
setRoute: (pattern) => {
|
|
90
|
+
route = pattern;
|
|
91
|
+
},
|
|
92
|
+
setSignedIn: (value) => {
|
|
93
|
+
signedIn = value;
|
|
94
|
+
},
|
|
95
|
+
install: (queryClient, target) => {
|
|
96
|
+
const onError = (event) => {
|
|
97
|
+
const { error, message } = event;
|
|
98
|
+
report("window", error ?? message);
|
|
99
|
+
};
|
|
100
|
+
const onRejection = (event) => {
|
|
101
|
+
const { reason } = event;
|
|
102
|
+
// An AppError is a request's answer: the caches below report the
|
|
103
|
+
// ones that are bugs, and the rest are expected.
|
|
104
|
+
if (isAppError(reason))
|
|
105
|
+
return;
|
|
106
|
+
report("unhandledrejection", reason);
|
|
107
|
+
};
|
|
108
|
+
target.addEventListener("error", onError);
|
|
109
|
+
target.addEventListener("unhandledrejection", onRejection);
|
|
110
|
+
const unsubscribeQueries = queryClient.getQueryCache().subscribe((event) => {
|
|
111
|
+
if (event.type === "updated" && event.action.type === "error")
|
|
112
|
+
reportAppError("query", event.action.error, "GET");
|
|
113
|
+
});
|
|
114
|
+
const unsubscribeMutations = queryClient.getMutationCache().subscribe((event) => {
|
|
115
|
+
if (event.type === "updated" && event.action.type === "error")
|
|
116
|
+
reportAppError("mutation", event.action.error);
|
|
117
|
+
});
|
|
118
|
+
return () => {
|
|
119
|
+
target.removeEventListener("error", onError);
|
|
120
|
+
target.removeEventListener("unhandledrejection", onRejection);
|
|
121
|
+
unsubscribeQueries();
|
|
122
|
+
unsubscribeMutations();
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export { returnPath } from './routes/return-path.js';
|
|
|
27
27
|
export { AuthProvider, useAuth } from './auth/use-auth.js';
|
|
28
28
|
export type { AuthContextValue, AuthStatus, AuthUser, SignInResult } from './auth/auth-context.js';
|
|
29
29
|
export type { AppError, Page, QuerySpec } from './api/contracts.js';
|
|
30
|
+
export type { ErrorReportingOptions } from './error-reporting/reporter.js';
|
|
30
31
|
export { createQueryClient } from './api/query-client.js';
|
|
31
32
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
32
33
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { components } from '../api/schema.js';
|
|
2
|
+
type ClientErrorReport = components['schemas']['ClientErrorReport'];
|
|
3
|
+
/** What POST /client-errors has received since the last reset. */
|
|
4
|
+
export declare function getMockClientErrors(): readonly ClientErrorReport[];
|
|
5
|
+
export declare function resetMockClientErrors(): void;
|
|
6
|
+
export declare const clientErrorHandlers: import("msw").HttpHandler[];
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// MSW handler for POST /client-errors (openapi/foundation.yaml), for an app
|
|
2
|
+
// that turns on FoundationProviders' errorReporting. It answers 204 like
|
|
3
|
+
// the reference backend and keeps what it was sent, so an app's tests can
|
|
4
|
+
// check a report was made and what it held. Like the backend, it needs the
|
|
5
|
+
// mock session.
|
|
6
|
+
import { http, HttpResponse } from 'msw';
|
|
7
|
+
import { isMockAuthenticated } from './auth.js';
|
|
8
|
+
let received = [];
|
|
9
|
+
/** What POST /client-errors has received since the last reset. */
|
|
10
|
+
export function getMockClientErrors() {
|
|
11
|
+
return received;
|
|
12
|
+
}
|
|
13
|
+
export function resetMockClientErrors() {
|
|
14
|
+
received = [];
|
|
15
|
+
}
|
|
16
|
+
export const clientErrorHandlers = [
|
|
17
|
+
http.post('*/api/client-errors', async ({ request }) => {
|
|
18
|
+
if (!isMockAuthenticated()) {
|
|
19
|
+
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
|
|
20
|
+
}
|
|
21
|
+
received = [...received, (await request.json())];
|
|
22
|
+
return new HttpResponse(null, { status: 204 });
|
|
23
|
+
}),
|
|
24
|
+
];
|
package/dist/mocks/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_RECOVERY_CODE, MOCK_TOTP_CODE, MOCK_TOTP_USER, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
2
|
+
export { clientErrorHandlers, getMockClientErrors, resetMockClientErrors } from './client-errors.js';
|
|
2
3
|
export { environmentHandlers } from './environment.js';
|
|
3
4
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
|
4
5
|
export type { MswHandle, MswOverride } from './override.js';
|
package/dist/mocks/index.js
CHANGED
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
// so msw (an optional peer dependency) never reaches an app's main bundle
|
|
3
3
|
// unless it imports this.
|
|
4
4
|
export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_RECOVERY_CODE, MOCK_TOTP_CODE, MOCK_TOTP_USER, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
5
|
+
export { clientErrorHandlers, getMockClientErrors, resetMockClientErrors } from './client-errors.js';
|
|
5
6
|
export { environmentHandlers } from './environment.js';
|
|
6
7
|
export { exposeMswForE2E } from './e2e-hooks.js';
|
package/openapi/foundation.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
openapi: 3.1.0
|
|
2
2
|
info:
|
|
3
3
|
title: UI Foundation — the contract every app shares
|
|
4
|
-
version: "4.
|
|
4
|
+
version: "4.1.0"
|
|
5
5
|
description: >
|
|
6
6
|
The part of an app's API contract that the foundation package's own code
|
|
7
7
|
calls: session-cookie auth under /auth/*, and the two error envelopes the
|
|
@@ -136,6 +136,31 @@ paths:
|
|
|
136
136
|
schema: { $ref: "#/components/schemas/DataEnvironment" }
|
|
137
137
|
"500": { $ref: "#/components/responses/ServerError" }
|
|
138
138
|
|
|
139
|
+
/client-errors:
|
|
140
|
+
x-optional: true
|
|
141
|
+
post:
|
|
142
|
+
operationId: reportClientError
|
|
143
|
+
summary: A failure in the browser, for the backend to forward to error reporting
|
|
144
|
+
description: >
|
|
145
|
+
Sent by the package when an app turns on FoundationProviders'
|
|
146
|
+
errorReporting. Needs a session, so the public can't post into it.
|
|
147
|
+
The backend may drop a report (a duplicate, too many) and still
|
|
148
|
+
answer 204. The user is the session's; the report never carries
|
|
149
|
+
request or response bodies, cookies, headers, form values or
|
|
150
|
+
query strings.
|
|
151
|
+
tags: [errors]
|
|
152
|
+
requestBody:
|
|
153
|
+
required: true
|
|
154
|
+
content:
|
|
155
|
+
application/json:
|
|
156
|
+
schema: { $ref: "#/components/schemas/ClientErrorReport" }
|
|
157
|
+
responses:
|
|
158
|
+
"204":
|
|
159
|
+
description: Received (forwarded, or dropped as a duplicate).
|
|
160
|
+
"401": { $ref: "#/components/responses/UnauthorizedError" }
|
|
161
|
+
"422": { $ref: "#/components/responses/ValidationError" }
|
|
162
|
+
"500": { $ref: "#/components/responses/ServerError" }
|
|
163
|
+
|
|
139
164
|
components:
|
|
140
165
|
schemas:
|
|
141
166
|
DataEnvironment:
|
|
@@ -191,6 +216,25 @@ components:
|
|
|
191
216
|
name: { $ref: "#/components/schemas/User/properties/name" }
|
|
192
217
|
password: { $ref: "#/components/schemas/LoginRequest/properties/password" }
|
|
193
218
|
|
|
219
|
+
ClientErrorReport:
|
|
220
|
+
type: object
|
|
221
|
+
required: [source, type, message]
|
|
222
|
+
properties:
|
|
223
|
+
source:
|
|
224
|
+
type: string
|
|
225
|
+
enum: [route, window, unhandledrejection, query, mutation]
|
|
226
|
+
description: Where the package caught it.
|
|
227
|
+
type: { type: string, minLength: 1, maxLength: 200, description: "The error's type (TypeError, ServerError)." }
|
|
228
|
+
message: { type: string, maxLength: 2000 }
|
|
229
|
+
stack: { type: string, maxLength: 16000 }
|
|
230
|
+
route:
|
|
231
|
+
type: string
|
|
232
|
+
maxLength: 300
|
|
233
|
+
description: The route pattern (/items/:id), never the URL.
|
|
234
|
+
method: { type: string, maxLength: 10, description: The HTTP method of a failed request. }
|
|
235
|
+
status: { type: integer, minimum: 100, maximum: 599, description: The HTTP status of a failed request. }
|
|
236
|
+
release: { type: string, maxLength: 100, description: The frontend build (its commit). }
|
|
237
|
+
|
|
194
238
|
ValidationErrorBody:
|
|
195
239
|
type: object
|
|
196
240
|
description: FastAPI's default 422 shape (RequestValidationError).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|