/**
* Custom error classes for Rango
*
* All errors include:
* - Descriptive names for easy identification
* - Cause property for structured debugging data
*/
/**
* Options for error construction
*/
interface ErrorOptions {
cause?: unknown;
}
/**
* Thrown when no route matches the requested pathname
*/
export class RouteNotFoundError extends Error {
name = "RouteNotFoundError" as const;
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
Object.setPrototypeOf(this, RouteNotFoundError.prototype);
this.cause = options?.cause;
}
}
// name fallback covers cross-realm errors (Vite dev dupes, RSC serialization)
// where instanceof fails.
export function isRouteNotFoundError(
error: unknown,
): error is RouteNotFoundError {
return (
error instanceof RouteNotFoundError ||
(error instanceof Error && error.name === "RouteNotFoundError")
);
}
/**
* Thrown when data is not found (e.g., product with ID doesn't exist)
* Use this in handlers/loaders to trigger the nearest notFoundBoundary
*
* @example
* ```typescript
* route("products.detail", async (ctx) => {
* const product = await db.products.get(ctx.params.slug);
* if (!product) throw new DataNotFoundError("Product not found");
* return ;
* });
* ```
*/
export class DataNotFoundError extends Error {
name = "DataNotFoundError" as const;
cause?: unknown;
constructor(message: string = "Not found", options?: ErrorOptions) {
super(message);
Object.setPrototypeOf(this, DataNotFoundError.prototype);
this.cause = options?.cause;
}
}
/**
* Convenience function to throw a DataNotFoundError
* Shorter syntax for common not-found scenarios
*
* @example
* ```typescript
* const product = await db.products.get(slug);
* if (!product) throw notFound("Product not found");
* // or simply:
* if (!product) throw notFound();
* ```
*/
export function notFound(message?: string): never {
throw new DataNotFoundError(message);
}
/**
* Thrown when middleware execution fails
*/
export class MiddlewareError extends Error {
name = "MiddlewareError" as const;
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
Object.setPrototypeOf(this, MiddlewareError.prototype);
this.cause = options?.cause;
}
}
/**
* Thrown when a route handler fails
*/
export class HandlerError extends Error {
name = "HandlerError" as const;
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
Object.setPrototypeOf(this, HandlerError.prototype);
this.cause = options?.cause;
}
}
/**
* Thrown when segment building fails
*/
export class BuildError extends Error {
name = "BuildError" as const;
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
Object.setPrototypeOf(this, BuildError.prototype);
this.cause = options?.cause;
}
}
/**
* Thrown when a route-definition DSL helper (route/layout/loader/cache/…) is
* called outside an active urls()/map() builder, so there is no
* AsyncLocalStorage build context to attach to. The message names the specific
* helper and how to fix it; the `cause` records the mechanical reason so the
* failure mode is identifiable (not conflated with an unrelated throw).
*/
export class DslContextError extends Error {
name = "DslContextError" as const;
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
Object.setPrototypeOf(this, DslContextError.prototype);
this.cause = options?.cause;
}
}
/**
* Thrown when a network request fails (server unreachable, no internet, etc.)
* This error triggers the root error boundary with retry capability.
*
* @example
* ```typescript
* try {
* await fetch(url);
* } catch (error) {
* if (error instanceof TypeError) {
* throw new NetworkError("Unable to connect to server", { cause: error });
* }
* throw error;
* }
* ```
*/
export class NetworkError extends Error {
name = "NetworkError" as const;
cause?: unknown;
/** The URL that failed to fetch */
url?: string;
/** Whether this was during an action, navigation, or revalidation */
operation?: "action" | "navigation" | "revalidation";
constructor(
message: string = "Network request failed",
options?: ErrorOptions & {
url?: string;
operation?: "action" | "navigation" | "revalidation";
},
) {
super(message);
Object.setPrototypeOf(this, NetworkError.prototype);
this.cause = options?.cause;
this.url = options?.url;
this.operation = options?.operation;
}
}
/**
* Check if an error is a network-level failure (server unreachable, no internet)
* These are typically TypeError from fetch when the network request itself fails.
*/
export function isNetworkError(error: unknown): boolean {
// NetworkError we throw ourselves
if (error instanceof NetworkError) {
return true;
}
// TypeError from fetch() when network fails (e.g., "Failed to fetch")
if (error instanceof TypeError) {
const message = error.message.toLowerCase();
return (
message.includes("failed to fetch") ||
message.includes("network request failed") ||
message.includes("networkerror") ||
message.includes("load failed")
);
}
// DOMException with network-related names
if (error instanceof DOMException) {
return error.name === "NetworkError";
}
return false;
}
/**
* Structured error for JSON response routes.
* Thrown by handlers to return a typed error envelope with a specific HTTP status.
*
* Unlike standard Error, RouterError messages are always exposed to the client
* (the developer intentionally crafted them for consumers).
*
* @example
* ```typescript
* path("/products/:id", (ctx) => {
* const product = products.find(p => p.id === ctx.params.id);
* if (!product) throw new RouterError("NOT_FOUND", "Product not found", { status: 404 });
* return product;
* }, { name: "productDetail" })
* ```
*/
export class RouterError extends Error {
name = "RouterError" as const;
code: string;
type?: string;
status: number;
cause?: unknown;
constructor(
code: string,
message: string,
options?: {
status?: number;
type?: string;
cause?: unknown;
},
) {
super(message);
Object.setPrototypeOf(this, RouterError.prototype);
this.code = code;
this.status = options?.status ?? 500;
this.type = options?.type;
this.cause = options?.cause;
}
}
/**
* Thrown inside a Prerender or Static handler at build time to signal
* "skip this entry, log it, and continue with the rest."
*
* When thrown, the entry is excluded from the pre-render manifest
* but the build continues normally. Regular throws (non-Skip)
* stop all further pre-rendering and fail the build.
*
* @example
* ```typescript
* export const BlogPost = Prerender(
* async () => allPosts.map(p => ({ slug: p.slug })),
* async (ctx) => {
* const post = await getPost(ctx.params.slug);
* if (post.draft) throw new Skip(`"${ctx.params.slug}" is a draft`);
* return ;
* },
* );
* ```
*/
export class Skip extends Error {
name = "Skip" as const;
cause?: unknown;
constructor(message: string = "Entry skipped", options?: ErrorOptions) {
super(message);
Object.setPrototypeOf(this, Skip.prototype);
this.cause = options?.cause;
}
}
/**
* Type guard to check if a thrown value is a Skip signal.
*/
export function isSkip(value: unknown): value is Skip {
return value instanceof Skip;
}
/**
* Thrown by the partial updater when the server responds with a redirect payload
* that carries location state. Caught by navigate() to re-navigate to the
* redirect target with the server-set state merged into history.pushState.
*
* Not an Error subclass -- this is a control flow signal, not a failure.
*/
export class ServerRedirect {
readonly name = "ServerRedirect";
constructor(
public readonly url: string,
public readonly state: Record | undefined,
) {}
}
/**
* Thrown when route handler returns invalid type
*/
export class InvalidHandlerError extends Error {
name = "InvalidHandlerError" as const;
cause?: unknown;
constructor(message: string, options?: ErrorOptions) {
super(message);
Object.setPrototypeOf(this, InvalidHandlerError.prototype);
this.cause = options?.cause;
}
}
/**
* Internal invariant assertion for development-time checks
*
* @internal
* @param condition - Condition that must be true
* @param message - Error message to throw if condition is false
* @throws {Error} If condition is false
*
* @example
* ```typescript
* invariant(user !== null, 'User must be defined');
* invariant(node.type === 'layout', `Expected layout, got ${node.type}`);
* ```
*/
export function invariant(condition: any, message: string): asserts condition {
if (!condition) {
throw new Error(`Invariant: ${message}`);
}
}
/**
* Sanitize errors for production - prevents leaking sensitive information
*
* SECURITY CRITICAL:
* - In production: NEVER send stack traces, file paths, or internal state
* - In development: Show full error details for debugging
* - ALWAYS consume error.stack to prevent memory leaks
*
* @param error - Error to sanitize
* @returns Response suitable for sending to client
*/
export function sanitizeError(error: unknown): Response {
// CRITICAL: Consume stack trace to prevent memory leaks
if (error && typeof error === "object" && "stack" in error) {
const _ = error.stack; // Force V8 to generate/consume stack trace
}
// Response objects are safe to return as-is
if (error instanceof Response) {
return error;
}
// Vite replaces import.meta.env.DEV at compile time. The fallback covers
// non-Vite environments (plain Node, test runners without Vite transforms).
// SECURITY: fail closed — default to production when the environment is ambiguous.
const isDev =
(import.meta as any).env?.DEV ??
globalThis.process?.env?.NODE_ENV === "development";
if (isDev) {
// Development: Send full error details for debugging
const errorDetails = {
name: error instanceof Error ? error.name : "Error",
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
cause:
error && typeof error === "object" && "cause" in error
? (error as any).cause
: undefined,
};
return new Response(JSON.stringify(errorDetails, null, 2), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
// Production: Generic error only - NO internal details
// SECURITY: Never leak stack traces, file paths, or application state
return new Response("Internal Server Error", {
status: 500,
headers: {
"Content-Type": "text/plain",
},
});
}