diff --git a/apps/cyberstorm-remix/cyberstorm/utils/__tests__/searchParamsUtils.test.ts b/apps/cyberstorm-remix/cyberstorm/utils/__tests__/searchParamsUtils.test.ts new file mode 100644 index 000000000..4c9cf2cf8 --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/__tests__/searchParamsUtils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + parseIntegerSearchParam, + setParamsBlobValue, +} from "../searchParamsUtils"; + +describe("setParamsBlobValue", () => { + it("returns a function that updates the blob with the new value", () => { + const setter = vi.fn(); + const oldBlob = { foo: "bar", baz: 1 }; + const key = "foo"; + + const updateFoo = setParamsBlobValue(setter, oldBlob, key); + updateFoo("qux"); + + expect(setter).toHaveBeenCalledWith({ foo: "qux", baz: 1 }); + }); + + it("adds a new key if it did not exist", () => { + const setter = vi.fn(); + const oldBlob: { foo: string; baz?: number } = { foo: "bar" }; + const key = "baz"; + + const updateBaz = setParamsBlobValue(setter, oldBlob, key); + updateBaz(2); + + expect(setter).toHaveBeenCalledWith({ foo: "bar", baz: 2 }); + }); +}); + +describe("parseIntegerSearchParam", () => { + it("returns undefined for null", () => { + expect(parseIntegerSearchParam(null)).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(parseIntegerSearchParam("")).toBeUndefined(); + }); + + it("returns undefined for whitespace string", () => { + expect(parseIntegerSearchParam(" ")).toBeUndefined(); + }); + + it("returns undefined for non-numeric string", () => { + expect(parseIntegerSearchParam("abc")).toBeUndefined(); + expect(parseIntegerSearchParam("123a")).toBeUndefined(); + expect(parseIntegerSearchParam("a123")).toBeUndefined(); + }); + + it("returns undefined for float string", () => { + expect(parseIntegerSearchParam("12.34")).toBeUndefined(); + }); + + it("returns integer for valid integer string", () => { + expect(parseIntegerSearchParam("123")).toBe(123); + expect(parseIntegerSearchParam("0")).toBe(0); + expect(parseIntegerSearchParam(" 456 ")).toBe(456); + }); + + it("returns undefined for unsafe integers", () => { + // Number.MAX_SAFE_INTEGER is 9007199254740991 + const unsafe = "9007199254740992"; + expect(parseIntegerSearchParam(unsafe)).toBeUndefined(); + }); +}); diff --git a/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/NimbusErrorBoundary.css b/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/NimbusErrorBoundary.css new file mode 100644 index 000000000..bab2cb5b6 --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/NimbusErrorBoundary.css @@ -0,0 +1,45 @@ +@layer nimbus-components { + .nimbus-error-boundary { + display: flex; + flex-direction: column; + gap: var(--gap-sm); + align-items: flex-start; + padding: var(--space-20); + border: 1px solid rgb(61 78 159 / 0.35); + border-radius: var(--radius-lg); + background: linear-gradient( + 135deg, + rgb(29 41 95 / 0.35), + rgb(19 26 68 / 0.5) + ); + transition: + background 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease; + } + + .nimbus-error-boundary:hover { + border-color: rgb(103 140 255 / 0.55); + background: linear-gradient( + 135deg, + rgb(40 58 132 / 0.45), + rgb(24 32 86 / 0.65) + ); + box-shadow: 0 0.5rem 1.25rem rgb(29 36 82 / 0.45); + } + + .nimbus-error-boundary__actions { + display: flex; + gap: var(--gap-xs); + align-self: stretch; + } + + .nimbus-error-boundary__button { + flex-grow: 1; + } + + .nimbus-error-boundary__description { + color: var(--color-text-secondary); + line-height: var(--line-height-md); + } +} diff --git a/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/NimbusErrorBoundary.tsx b/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/NimbusErrorBoundary.tsx new file mode 100644 index 000000000..b6e3fe163 --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/NimbusErrorBoundary.tsx @@ -0,0 +1,176 @@ +import { Component, type ErrorInfo, type ReactNode, useCallback } from "react"; +import { useAsyncError, useLocation, useRouteError } from "react-router"; + +import { NewButton } from "@thunderstore/cyberstorm"; +import { classnames } from "@thunderstore/cyberstorm"; + +import { + resolveRouteErrorPayload, + safeResolveRouteErrorPayload, +} from "../resolveRouteErrorPayload"; +import "./NimbusErrorBoundary.css"; + +interface NimbusErrorBoundaryState { + error: Error | null; +} + +export interface NimbusErrorRetryHandlerArgs { + error: unknown; + reset: () => void; +} + +export interface NimbusErrorBoundaryProps { + children: ReactNode; + title?: string; + description?: string; + retryLabel?: string; + onError?: (error: Error, info: ErrorInfo) => void; + onReset?: () => void; + fallback?: React.ComponentType; + onRetry?: (args: NimbusErrorRetryHandlerArgs) => void; + fallbackClassName?: string; +} + +export interface NimbusErrorBoundaryFallbackProps { + error: unknown; + reset?: () => void; + title?: string; + description?: string; + retryLabel?: string; + onRetry?: (args: NimbusErrorRetryHandlerArgs) => void; + className?: string; +} + +export type NimbusAwaitErrorElementProps = Pick< + NimbusErrorBoundaryFallbackProps, + "title" | "description" | "retryLabel" | "className" | "onRetry" +>; + +/** + * NimbusErrorBoundary isolates rendering failures within a subtree and surfaces + * a consistent recovery UI with an optional "Retry" affordance. + */ +export class NimbusErrorBoundary extends Component< + NimbusErrorBoundaryProps, + NimbusErrorBoundaryState +> { + public state: NimbusErrorBoundaryState = { + error: null, + }; + + public static getDerivedStateFromError( + error: Error + ): NimbusErrorBoundaryState { + return { error }; + } + + public componentDidCatch(error: Error, info: ErrorInfo) { + this.props.onError?.(error, info); + } + + private readonly resetBoundary = () => { + this.setState({ error: null }, () => { + this.props.onReset?.(); + }); + }; + + public override render() { + const { error } = this.state; + + if (error) { + const FallbackComponent = + this.props.fallback ?? NimbusErrorBoundaryFallback; + + return ( + + ); + } + + return this.props.children; + } +} + +/** + * Default fallback surface displayed by {@link NimbusErrorBoundary}. It derives + * user-facing messaging from the captured error when possible and offers a + * retry button that either resets the boundary or runs a custom handler. + */ +export function NimbusErrorBoundaryFallback( + props: NimbusErrorBoundaryFallbackProps +) { + const { error, reset, onRetry, className } = props; + const { pathname, search, hash } = useLocation(); + + const payload = safeResolveRouteErrorPayload(error); + const title = props.title ?? payload?.headline ?? "Something went wrong"; + const description = + props.description ?? payload?.description ?? "Please try again."; + const retryLabel = props.retryLabel ?? "Retry"; + const currentLocation = `${pathname}${search}${hash}`; + const rootClassName = classnames( + "container container--y container--full nimbus-error-boundary", + className + ); + + const noopReset = useCallback(() => {}, []); + const safeReset = reset ?? noopReset; + + const handleRetry = useCallback(() => { + if (onRetry) { + onRetry({ error, reset: safeReset }); + return; + } + + window.location.assign(currentLocation); + }, [currentLocation, error, onRetry, safeReset]); + + return ( +
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ + {retryLabel} + +
+
+ ); +} + +/** + * Generic Await error element that mirrors {@link NimbusErrorBoundaryFallback} + * behaviour by surfacing the async error alongside Nimbus styling. + */ +export function NimbusAwaitErrorElement(props: NimbusAwaitErrorElementProps) { + const error = useAsyncError(); + + return ; +} + +export function NimbusDefaultRouteErrorBoundary() { + const error = useRouteError(); + const payload = resolveRouteErrorPayload(error); + + return ( + + ); +} diff --git a/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/index.ts b/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/index.ts new file mode 100644 index 000000000..6641a6db1 --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/index.ts @@ -0,0 +1 @@ +export * from "./NimbusErrorBoundary"; diff --git a/apps/cyberstorm-remix/cyberstorm/utils/errors/handleLoaderError.ts b/apps/cyberstorm-remix/cyberstorm/utils/errors/handleLoaderError.ts new file mode 100644 index 000000000..a5ad2f50e --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/errors/handleLoaderError.ts @@ -0,0 +1,80 @@ +import { + ApiError, + type UserFacingErrorCategory, + mapApiErrorToUserFacingError, +} from "@thunderstore/thunderstore-api"; + +import { defaultErrorMappings } from "./loaderMappings"; +import { + type CreateUserFacingErrorResponseOptions, + type UserFacingErrorPayload, + throwUserFacingErrorResponse, + throwUserFacingPayloadResponse, +} from "./userFacingErrorResponse"; + +export interface LoaderErrorMapping { + status: number; + headline: string; + description?: string; + category?: UserFacingErrorCategory; + includeContext?: boolean; + statusOverride?: number; +} + +export interface HandleLoaderErrorOptions + extends CreateUserFacingErrorResponseOptions { + mappings?: LoaderErrorMapping[]; +} + +/** + * Normalises unknown loader errors, promoting mapped API errors to user-facing payloads + * and rethrowing everything else via `throwUserFacingErrorResponse`. + */ +export function handleLoaderError( + error: unknown, + options?: HandleLoaderErrorOptions +): never { + if (error instanceof Response) { + throw error; + } + + const resolvedOptions: HandleLoaderErrorOptions = options ?? {}; + const allOptions = defaultErrorMappings.concat( + resolvedOptions.mappings ?? [] + ); + + if (error instanceof ApiError && allOptions.length) { + const mapping = allOptions.findLast((candidate) => { + const statuses = Array.isArray(candidate.status) + ? candidate.status + : [candidate.status]; + return statuses.includes(error.response.status); + }); + + if (mapping) { + const base = mapApiErrorToUserFacingError( + error, + resolvedOptions.mapOptions + ); + const payload: UserFacingErrorPayload = { + headline: mapping.headline, + description: mapping.description ?? base.description, + category: mapping.category ?? base.category, + status: mapping.statusOverride ?? base.status, + }; + + payload.context = + base.context && + (mapping.includeContext ?? resolvedOptions.includeContext ?? false) + ? base.context + : undefined; + + throwUserFacingPayloadResponse(payload, { + statusOverride: + mapping.statusOverride ?? resolvedOptions.statusOverride, + }); + } + } + + throwUserFacingErrorResponse(error, resolvedOptions); +} diff --git a/apps/cyberstorm-remix/cyberstorm/utils/errors/loaderMappings.ts b/apps/cyberstorm-remix/cyberstorm/utils/errors/loaderMappings.ts new file mode 100644 index 000000000..4909498ed --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/errors/loaderMappings.ts @@ -0,0 +1,81 @@ +import type { LoaderErrorMapping } from "./handleLoaderError"; + +export const SIGN_IN_REQUIRED_MAPPING: LoaderErrorMapping = { + status: 401, + headline: "Sign in required.", + description: "Please sign in to continue.", + category: "auth", +}; + +export const FORBIDDEN_MAPPING: LoaderErrorMapping = { + status: 403, + headline: "You do not have permission to perform this action.", + description: "Contact a team administrator to request access.", + category: "auth", +}; + +export const VALIDATION_MAPPING: LoaderErrorMapping = { + status: 422, + headline: "Invalid request.", + description: "Please review the provided data and try again.", + category: "validation", +}; + +export const CONFLICT_MAPPING: LoaderErrorMapping = { + status: 409, + headline: "Action could not be completed.", + description: + "Another change was made at the same time. Refresh and try again.", + category: "server", +}; + +export const RATE_LIMIT_MAPPING: LoaderErrorMapping = { + status: 429, + headline: "Too many requests.", + description: "Please wait a moment and try again.", + category: "rate_limit", +}; + +/** + * Creates a reusable server-error mapping with configurable copy and status. + */ +export function createServerErrorMapping( + headline = "Something went wrong.", + description = "Please try again in a moment.", + status = 500 +): LoaderErrorMapping { + return { + status, + headline, + description, + category: "server", + }; +} + +/** + * Creates a not-found mapping for loader routes that present custom messaging. + */ +export function createNotFoundMapping( + headline: string, + description: string, + status = 404 +): LoaderErrorMapping { + return { + status, + headline, + description, + category: "not_found", + }; +} + +export const defaultErrorMappings: LoaderErrorMapping[] = [ + SIGN_IN_REQUIRED_MAPPING, + FORBIDDEN_MAPPING, + VALIDATION_MAPPING, + createServerErrorMapping(), + createNotFoundMapping( + "Resource not found.", + "We could not find the requested resource.", + 404 + ), +]; diff --git a/apps/cyberstorm-remix/cyberstorm/utils/errors/resolveRouteErrorPayload.ts b/apps/cyberstorm-remix/cyberstorm/utils/errors/resolveRouteErrorPayload.ts new file mode 100644 index 000000000..09c3964ed --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/errors/resolveRouteErrorPayload.ts @@ -0,0 +1,78 @@ +import { isRouteErrorResponse } from "react-router"; + +import { + ApiError, + UserFacingError, + mapApiErrorToUserFacingError, +} from "@thunderstore/thunderstore-api"; + +import { + type UserFacingErrorPayload, + parseUserFacingErrorPayload, +} from "./userFacingErrorResponse"; + +/** + * Converts a domain user-facing error into the serialisable payload shape. + */ +function toPayloadFromUserFacing( + error: UserFacingError +): UserFacingErrorPayload { + return { + headline: error.headline, + description: error.description, + category: error.category, + status: error.status, + context: error.context, + }; +} + +/** + * Normalizes various error shapes thrown during routing into a consistent payload + * that components can render without duplicating mapping logic. + */ +export function resolveRouteErrorPayload( + error: unknown +): UserFacingErrorPayload { + if (isRouteErrorResponse(error)) { + const parsed = parseUserFacingErrorPayload(error.data); + if (parsed) { + return parsed; + } + + return { + headline: error.statusText || "Something went wrong.", + description: typeof error.data === "string" ? error.data : undefined, + category: "server", + status: error.status, + }; + } + + if (error instanceof ApiError) { + return toPayloadFromUserFacing(mapApiErrorToUserFacingError(error)); + } + + const parsed = parseUserFacingErrorPayload(error); + if (parsed) { + return parsed; + } + + return { + headline: error instanceof Error ? error.message : "Something went wrong.", + description: undefined, + category: "server", + status: 500, + }; +} + +/** + * Attempts to derive a user-facing payload from the thrown error without letting + * mapper issues break the fallback UI. + */ +export function safeResolveRouteErrorPayload(error: unknown) { + try { + return resolveRouteErrorPayload(error); + } catch (resolutionError) { + console.error("Failed to resolve route error payload", resolutionError); + return null; + } +} diff --git a/apps/cyberstorm-remix/cyberstorm/utils/errors/userFacingErrorResponse.ts b/apps/cyberstorm-remix/cyberstorm/utils/errors/userFacingErrorResponse.ts new file mode 100644 index 000000000..5b8c619e7 --- /dev/null +++ b/apps/cyberstorm-remix/cyberstorm/utils/errors/userFacingErrorResponse.ts @@ -0,0 +1,131 @@ +import { + type MapUserFacingErrorOptions, + type UserFacingError, + type UserFacingErrorCategory, + mapApiErrorToUserFacingError, +} from "@thunderstore/thunderstore-api"; + +/** + * Serializable data presented to users when API or loader errors occur. + */ +export interface UserFacingErrorPayload { + headline: string; + description?: string; + category: UserFacingErrorCategory; + status?: number; + context?: Record; +} + +/** + * Controls how auxiliary context is included when mapping errors. + */ +export interface UserFacingPayloadOptions { + includeContext?: boolean; +} + +/** + * Configuration for constructing standardised user-facing error responses. + */ +export interface CreateUserFacingErrorResponseOptions { + mapOptions?: MapUserFacingErrorOptions; + statusOverride?: number; + includeContext?: boolean; +} + +/** + * Throws a Remix `Response` with a serialised user-facing error payload. + */ +export function throwUserFacingErrorResponse( + error: unknown, + options: CreateUserFacingErrorResponseOptions = {} +): never { + const userFacing = mapApiErrorToUserFacingError(error, options.mapOptions); + const payload = toPayload(userFacing, { + includeContext: options.includeContext ?? false, + }); + + throwUserFacingPayloadResponse(payload, { + statusOverride: options.statusOverride, + }); +} + +/** + * Throws a Remix `Response` for an already-created user-facing error payload. + */ +export function throwUserFacingPayloadResponse( + payload: UserFacingErrorPayload, + options: { statusOverride?: number } = {} +): never { + const status = options.statusOverride ?? payload.status ?? 500; + throw new Response(JSON.stringify(payload), { + status, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); +} + +/** + * Attempts to parse a user-facing payload from unknown data. + */ +export function parseUserFacingErrorPayload( + value: unknown +): UserFacingErrorPayload | null { + if (isUserFacingErrorPayload(value)) { + return value; + } + + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (isUserFacingErrorPayload(parsed)) { + return parsed; + } + } catch { + return null; + } + } + + return null; +} + +/** + * Runtime type guard for `UserFacingErrorPayload` values. + */ +export function isUserFacingErrorPayload( + value: unknown +): value is UserFacingErrorPayload { + if (!value || typeof value !== "object") { + return false; + } + + const maybePayload = value as Partial; + return ( + typeof maybePayload.headline === "string" && + typeof maybePayload.category === "string" + ); +} + +/** + * Internal helper that filters context inclusion for payload serialisation. + */ +function toPayload( + error: UserFacingError, + options: UserFacingPayloadOptions = {} +): UserFacingErrorPayload { + const includeContext = options.includeContext ?? false; + + const payload: UserFacingErrorPayload = { + headline: error.headline, + description: error.description, + category: error.category, + status: error.status, + }; + + if (includeContext && error.context) { + payload.context = error.context; + } + + return payload; +} diff --git a/apps/cyberstorm-remix/cyberstorm/utils/searchParamsUtils.ts b/apps/cyberstorm-remix/cyberstorm/utils/searchParamsUtils.ts index 546e277c5..23dca58dd 100644 --- a/apps/cyberstorm-remix/cyberstorm/utils/searchParamsUtils.ts +++ b/apps/cyberstorm-remix/cyberstorm/utils/searchParamsUtils.ts @@ -4,3 +4,18 @@ export function setParamsBlobValue< >(setter: (v: SearchParamsType) => void, oldBlob: SearchParamsType, key: K) { return (v: SearchParamsType[K]) => setter({ ...oldBlob, [key]: v }); } + +export function parseIntegerSearchParam(value: string | null) { + if (value === null) { + return undefined; + } + const trimmedValue = value.trim(); + if (!/^\d+$/.test(trimmedValue)) { + return undefined; + } + const parsed = Number.parseInt(trimmedValue, 10); + if (Number.isNaN(parsed) || !Number.isSafeInteger(parsed)) { + return undefined; + } + return parsed; +} diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 000000000..325494b09 --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,192 @@ +# Error Handling + +This document summarizes the Cyberstorm Remix error-handling strategy so every route surfaces consistent, user-friendly failures. + +The guidance is split into three layers: + +1. **Server loaders** – how to normalize API failures before they reach the UI. +2. **Client loaders** – how to stream data to Suspense while preserving the same user-facing payloads. +3. **Error boundaries** – how Nimbus components keep rendering errors isolated. + +--- + +## Server Loaders + +Server-side Remix `loader` functions must always translate thrown errors into `UserFacingError` payloads. Use `handleLoaderError` for every API call so mapped HTTP codes always render the same text. + +### `handleLoaderError` + +`handleLoaderError` accepts any unknown error, looks up optional mappings, and throws a `Response` created by `throwUserFacingPayloadResponse`. + +- **Source**: [`cyberstorm/utils/errors/handleLoaderError.ts`](../../apps/cyberstorm-remix/cyberstorm/utils/errors/handleLoaderError.ts) +- **Response helper**: [`cyberstorm/utils/errors/userFacingErrorResponse.ts`](../../apps/cyberstorm-remix/cyberstorm/utils/errors/userFacingErrorResponse.ts) + +```tsx +// apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx +import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError"; +import { getLoaderTools } from "cyberstorm/utils/getLoaderTools"; + +export async function loader({ params }: LoaderFunctionArgs) { + if (params.namespaceId && params.packageId) { + const { dapper } = getLoaderTools(); + try { + const readme = await dapper.getPackageReadme( + params.namespaceId, + params.packageId + ); + return { readme }; + } catch (error) { + handleLoaderError(error); + } + } + + throwUserFacingPayloadResponse({ + headline: "Package not found.", + category: "not_found", + status: 404, + }); +} +``` + +#### Why every loader uses it + +The phrase “all Remix loaders” includes *both* server `loader`s and client `clientLoader`s. Wrapping API calls this way guarantees: + +- Users always see the same copy (via shared mappings such as `packageNotFoundMappings`). +- Default mappings continue to work when route code omits explicit options; `handleLoaderError` now merges `defaultErrorMappings` with any route-specific overrides before searching for a match. +- Any unrecognized exception still flows through `throwUserFacingErrorResponse`, so the `ErrorBoundary` has structured data. + +### `UserFacingError` + +`handleLoaderError` ultimately throws a JSON payload shaped like `UserFacingError`, defined in [`packages/thunderstore-api`](../../packages/thunderstore-api/src/errors/userFacingError.ts). Each payload contains a `headline`, optional `description`, and any context needed by Nimbus error boundaries. + +--- + +## Client Loaders + +Client loaders stream data to the browser but should produce the same payloads and tone as their server counterparts. Treat them exactly like server loaders: + +1. Validate required params (`namespaceId`, `teamName`, etc.) and throw `throwUserFacingPayloadResponse` immediately when missing. +2. Call `getLoaderTools()` so you reuse the configured `DapperTs` instance *and* hydrated session tools. The helper now returns `{ dapper, sessionTools }` for client and server paths. +3. Wrap awaited work in `try/catch` and delegate to `handleLoaderError`. For Promises you hand off to Suspense, attach `.catch(error => handleLoaderError(error, { mappings }))` so rejections surface Nimbus copy. +4. Share error-mapping constants between server and client loaders to prevent drift. + +### Example pattern + +```tsx +const teamNotFoundMappings = [ + createNotFoundMapping( + "Team not found.", + "We could not find the requested team.", + 404 + ), +]; + +export async function clientLoader({ params, request }: Route.ClientLoaderArgs) { + if (params.communityId && params.namespaceId) { + const { dapper } = getLoaderTools(); + const page = parseIntegerSearchParam(new URL(request.url).searchParams.get("page")); + + return { + filters: dapper + .getCommunityFilters(params.communityId) + .catch((error) => handleLoaderError(error, { mappings: teamNotFoundMappings })), + listings: dapper + .getPackageListings( + { kind: "namespace", communityId: params.communityId, namespaceId: params.namespaceId }, + PackageOrderOptions.Updated, + page + ) + .catch((error) => handleLoaderError(error, { mappings: teamNotFoundMappings })), + }; + } + + throwUserFacingPayloadResponse({ + headline: "Community not found.", + description: "We could not find the requested community.", + category: "not_found", + status: 404, + }); +} +``` + +> **Tip:** Client loaders should only `await` dependent work (for example, when the second call needs data from the first). Everything else can be returned as promises so `` renders immediately. + +--- + +## Error Boundaries + +Rendering errors are isolated with Nimbus components so a failure in one subtree never crashes the entire shell. Treat React Router error primitives and Nimbus helpers as a single toolkit. + +### Route-level boundaries + +Every Remix route exports `ErrorBoundary = NimbusDefaultRouteErrorBoundary` (or a custom component that delegates to `NimbusErrorBoundaryFallback`). When a loader throws a `UserFacingError`, call `resolveRouteErrorPayload` inside the boundary to access the structured payload. + +```tsx +import { useRouteError } from "react-router"; +import { + NimbusDefaultRouteErrorBoundary, + NimbusErrorBoundaryFallback, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import { resolveRouteErrorPayload } from "cyberstorm/utils/errors/resolveRouteErrorPayload"; + +export function ErrorBoundary() { + const error = useRouteError(); + const payload = resolveRouteErrorPayload(error); + + return ( + + ); +} + +export const DefaultRouteBoundary = NimbusDefaultRouteErrorBoundary; +``` + +### Suspense + `Await` + +When a loader returns promises, wrap the consuming UI with `` and `` so loader errors resolve through Nimbus’ Suspense-aware helpers instead of crashing React Router. + +```tsx +import { Suspense } from "react"; +import { Await } from "react-router"; +import { SkeletonBox } from "@thunderstore/cyberstorm"; +import { NimbusAwaitErrorElement } from "cyberstorm/utils/errors/NimbusErrorBoundary"; + +export function ReadmeContent({ readme }: { readme: Promise }) { + return ( + }> + }> + {(resolvedReadme) => } + + + ); +} +``` + +### Granular component boundaries + +`NimbusErrorBoundary` can also wrap individual components when you need localized retries or messaging. + +```tsx +import { NimbusErrorBoundary } from "cyberstorm/utils/errors/NimbusErrorBoundary"; + +export function MyComponent() { + return ( + + + + ); +} +``` + +The full implementation lives in [`cyberstorm/utils/errors/NimbusErrorBoundary/NimbusErrorBoundary.tsx`](../../apps/cyberstorm-remix/cyberstorm/utils/errors/NimbusErrorBoundary/NimbusErrorBoundary.tsx), and the app shell wires it up in [`app/root.tsx`](../../apps/cyberstorm-remix/app/root.tsx). + +--- diff --git a/packages/thunderstore-api/src/errors/__tests__/sanitizeServerDetail.test.ts b/packages/thunderstore-api/src/errors/__tests__/sanitizeServerDetail.test.ts new file mode 100644 index 000000000..bf51db971 --- /dev/null +++ b/packages/thunderstore-api/src/errors/__tests__/sanitizeServerDetail.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { sanitizeServerDetail } from "../sanitizeServerDetail"; + +describe("sanitizeServerDetail", () => { + it("returns empty string for empty input", () => { + expect(sanitizeServerDetail("")).toBe(""); + }); + + it("removes control characters", () => { + // ASCII 0-31 and 127 are control characters + expect(sanitizeServerDetail("Hello\x00World")).toBe("Hello World"); + expect(sanitizeServerDetail("Hello\x1FWorld")).toBe("Hello World"); + expect(sanitizeServerDetail("Hello\x7FWorld")).toBe("Hello World"); + }); + + it("collapses whitespace", () => { + expect(sanitizeServerDetail(" Hello World ")).toBe("Hello World"); + expect(sanitizeServerDetail("Hello\t\nWorld")).toBe("Hello World"); + }); + + it("handles combination of control characters and whitespace", () => { + expect(sanitizeServerDetail(" Hello \x00 World ")).toBe("Hello World"); + }); + + it("returns empty string if result is only whitespace/control characters", () => { + expect(sanitizeServerDetail(" \x00 ")).toBe(""); + }); + + it("truncates long strings", () => { + const longString = "a".repeat(401); + const expected = "a".repeat(400) + "…"; + expect(sanitizeServerDetail(longString)).toBe(expected); + }); + + it("does not truncate strings of exactly max length", () => { + const longString = "a".repeat(400); + expect(sanitizeServerDetail(longString)).toBe(longString); + }); + + it("trims before truncating when slice ends in space", () => { + // "a" * 399 + " " + "b" + // cleaned: "a...a b" (length 401) + // slice(0, 400) -> "a...a " (length 400, ends in space) + // trim() -> "a...a" (length 399) + // result -> "a...a…" + const input = "a".repeat(399) + " b"; + const expected = "a".repeat(399) + "…"; + expect(sanitizeServerDetail(input)).toBe(expected); + }); + + it("preserves non-ASCII characters and emojis", () => { + expect(sanitizeServerDetail("Héllo Wörld")).toBe("Héllo Wörld"); + expect(sanitizeServerDetail("Hello 🌍 World")).toBe("Hello 🌍 World"); + }); +}); diff --git a/packages/thunderstore-api/src/errors/__tests__/userFacingError.test.ts b/packages/thunderstore-api/src/errors/__tests__/userFacingError.test.ts new file mode 100644 index 000000000..1d1581f40 --- /dev/null +++ b/packages/thunderstore-api/src/errors/__tests__/userFacingError.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { mapApiErrorToUserFacingError } from "../userFacingError"; + +describe("mapApiErrorToUserFacingError", () => { + it("identifies AbortError as network error", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + const result = mapApiErrorToUserFacingError(error); + expect(result.category).toBe("network"); + }); + + it("identifies 'Network Error' message as network error", () => { + const error = new Error("Network Error"); + const result = mapApiErrorToUserFacingError(error); + expect(result.category).toBe("network"); + }); + + it("identifies 'Failed to fetch' as network error", () => { + const error = new TypeError("Failed to fetch"); + const result = mapApiErrorToUserFacingError(error); + expect(result.category).toBe("network"); + }); + + it("identifies 'fetch failed' as network error", () => { + const error = new TypeError("fetch failed"); + const result = mapApiErrorToUserFacingError(error); + expect(result.category).toBe("network"); + }); + + it("identifies plain object with 'Network Error' message as network error", () => { + const error = { message: "Network Error" }; + const result = mapApiErrorToUserFacingError(error); + expect(result.category).toBe("network"); + }); + + it("identifies generic TypeError as unknown error", () => { + const error = new TypeError("Some random type error"); + const result = mapApiErrorToUserFacingError(error); + expect(result.category).toBe("unknown"); + }); + + it("identifies other errors as unknown", () => { + const error = new Error("Something else"); + const result = mapApiErrorToUserFacingError(error); + expect(result.category).toBe("unknown"); + }); +}); diff --git a/packages/thunderstore-api/src/errors/index.ts b/packages/thunderstore-api/src/errors/index.ts new file mode 100644 index 000000000..5803b93ff --- /dev/null +++ b/packages/thunderstore-api/src/errors/index.ts @@ -0,0 +1,2 @@ +export * from "./sanitizeServerDetail"; +export * from "./userFacingError"; diff --git a/packages/thunderstore-api/src/errors/sanitizeServerDetail.ts b/packages/thunderstore-api/src/errors/sanitizeServerDetail.ts new file mode 100644 index 000000000..def0285a5 --- /dev/null +++ b/packages/thunderstore-api/src/errors/sanitizeServerDetail.ts @@ -0,0 +1,32 @@ +const MAX_LENGTH = 400; + +function stripControlCharacters(value: string): string { + let result = ""; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + const code = char.charCodeAt(0); + if (code < 32 || code === 127) { + result += " "; + continue; + } + result += char; + } + return result; +} + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function sanitizeServerDetail(value: string): string { + if (!value) return ""; + + const cleaned = collapseWhitespace(stripControlCharacters(value)); + if (!cleaned) return ""; + + if (cleaned.length <= MAX_LENGTH) { + return cleaned; + } + + return `${cleaned.slice(0, MAX_LENGTH).trim()}…`; +} diff --git a/packages/thunderstore-api/src/errors/userFacingError.ts b/packages/thunderstore-api/src/errors/userFacingError.ts new file mode 100644 index 000000000..75632b33a --- /dev/null +++ b/packages/thunderstore-api/src/errors/userFacingError.ts @@ -0,0 +1,223 @@ +import { + ApiError, + ParseError, + RequestBodyParseError, + RequestQueryParamsParseError, +} from "../errors"; +import { sanitizeServerDetail } from "./sanitizeServerDetail"; + +export type UserFacingErrorCategory = + | "auth" + | "validation" + | "not_found" + | "rate_limit" + | "server" + | "network" + | "unknown"; + +export interface MapUserFacingErrorOptions { + fallbackHeadline?: string; + fallbackDescription?: string; + context?: Record; +} + +export interface CreateResourceNotFoundErrorArgs { + resourceName: string; + identifier?: string; + description?: string; + originalError: Error; + context?: Record; +} + +interface UserFacingErrorArgs { + category: UserFacingErrorCategory; + status?: number; + headline: string; + description?: string; + originalError: Error; + context?: Record; +} + +export class UserFacingError extends Error { + readonly category: UserFacingErrorCategory; + readonly status?: number; + readonly headline: string; + readonly description?: string; + readonly originalError: Error; + readonly context?: Record; + + constructor(args: UserFacingErrorArgs) { + super(args.headline); + this.category = args.category; + this.status = args.status; + this.headline = args.headline; + this.description = args.description; + this.originalError = args.originalError; + this.context = args.context; + this.name = "UserFacingError"; + } +} + +const RESOURCE_FALLBACK_LABEL = "resource"; + +const DEFAULT_HEADLINE = "Something went wrong."; +const DEFAULT_DESCRIPTION = "Please try again."; + +export interface FormatUserFacingErrorOptions { + fallback?: string; +} + +export function formatUserFacingError( + error: UserFacingError, + options: FormatUserFacingErrorOptions = {} +): string { + if (error.description) { + return `${error.headline} ${error.description}`; + } + if (options.fallback) { + return options.fallback; + } + return error.headline; +} + +export function mapApiErrorToUserFacingError( + error: unknown, + options: MapUserFacingErrorOptions = {} +): UserFacingError { + if (error instanceof UserFacingError) { + return error; + } + + const fallbackHeadline = options.fallbackHeadline ?? DEFAULT_HEADLINE; + const fallbackDescription = + options.fallbackDescription ?? DEFAULT_DESCRIPTION; + + if (error instanceof ApiError) { + const category = categorizeStatus(error.response.status); + const headline = sanitizeServerDetail(error.message) ?? fallbackHeadline; + const sanitizedServerDetail = sanitizeServerDetail(error.message ?? ""); + const sanitizedFallback = sanitizeServerDetail(fallbackDescription); + const descriptionCandidate = sanitizedServerDetail || sanitizedFallback; + + return new UserFacingError({ + category, + status: error.response.status, + headline, + description: descriptionCandidate, + originalError: error, + context: { + ...options.context, + statusText: error.response.statusText, + }, + }); + } + + if ( + error instanceof RequestBodyParseError || + error instanceof RequestQueryParamsParseError || + error instanceof ParseError + ) { + const description = sanitizeServerDetail(error.message); + return new UserFacingError({ + category: "validation", + headline: "Invalid request data.", + description: description || undefined, + originalError: error, + context: options.context, + }); + } + + const err = new Error(String(error)); + + if (isNetworkError(error)) { + return new UserFacingError({ + category: "network", + headline: fallbackHeadline, + description: fallbackDescription, + originalError: err, + context: options.context, + }); + } + + return new UserFacingError({ + category: "unknown", + headline: fallbackHeadline, + description: fallbackDescription, + originalError: err, + context: options.context, + }); +} + +/** + * Utility for generating consistent not-found errors with optional identifiers. + */ +export function createResourceNotFoundError( + args: CreateResourceNotFoundErrorArgs +): UserFacingError { + const resourceName = + sanitizeServerDetail(args.resourceName) || RESOURCE_FALLBACK_LABEL; + const identifier = args.identifier + ? sanitizeServerDetail(args.identifier) + : undefined; + const headline = `${resourceName} not found.`; + const description = + (args.description ? sanitizeServerDetail(args.description) : undefined) ?? + (identifier + ? `We could not find the ${resourceName} "${identifier}".` + : `We could not find the requested ${resourceName}.`); + + return new UserFacingError({ + category: "not_found", + status: 404, + headline, + description, + originalError: args.originalError, + context: { + ...args.context, + resource: resourceName, + ...(identifier ? { identifier } : {}), + }, + }); +} + +function categorizeStatus(status: number): UserFacingErrorCategory { + if (status === 401 || status === 403) { + return "auth"; + } + if (status === 404) { + return "not_found"; + } + if (status === 422 || status === 400) { + return "validation"; + } + if (status === 429) { + return "rate_limit"; + } + if (status >= 500) { + return "server"; + } + return "unknown"; +} + +function isNetworkError(error: unknown): boolean { + if (error instanceof Error && error.name === "AbortError") { + return true; + } + + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message: unknown }).message === "string" + ) { + const message = (error as { message: string }).message; + return ( + message === "Abort Error" || + message === "Network Error" || + message === "Failed to fetch" || + message === "fetch failed" + ); + } + + return false; +} diff --git a/packages/thunderstore-api/src/index.ts b/packages/thunderstore-api/src/index.ts index 9e4b0ffae..0def24c62 100644 --- a/packages/thunderstore-api/src/index.ts +++ b/packages/thunderstore-api/src/index.ts @@ -54,6 +54,7 @@ export * from "./post/teamAddServiceAccount"; export * from "./post/teamMember"; export * from "./post/usermedia"; export * from "./errors"; +export * from "./errors/index"; export * from "./schemas/requestSchemas"; export * from "./schemas/responseSchemas"; export * from "./schemas/objectSchemas";