@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.
@@ -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
@@ -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
- export declare function FoundationProviders({ mockMode, queryClient, children, }: {
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
- return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: children }) })] }));
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
+ ];
@@ -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';
@@ -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';
@@ -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.0.0"
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.0.0",
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": {