Skip to content

Instantly share code, notes, and snippets.

@rphlmr
Created August 24, 2023 17:01
Show Gist options
  • Save rphlmr/11fd3be454a23491478262a775f5367c to your computer and use it in GitHub Desktop.
Save rphlmr/11fd3be454a23491478262a775f5367c to your computer and use it in GitHub Desktop.
Remix +
import { createId } from "@paralleldrive/cuid2";
/**
* @param message The message intended for the user.
*
* Other params are for logging purposes and help us debug.
* @param cause The error that caused the rejection.
* @param context Additional data to help us debug.
* @param name A name to help us debug and filter logs.
*
*/
type FailureReason = {
name:
| "Unknown 🐞"
| "Supabase integration ⚡️"
| "Invariant violation 👻"
| "Payload validation 👾"
| "Parameter validation 👾"
| "FormData validation 👾"
| "Auth 🔐"
| "Dev error 🤦‍♂️";
message: string;
cause?: unknown;
context?: Record<string, unknown>;
traceId?: string;
status?:
| 200 // ok
| 204 // no content
| 400 // bad request
| 401 // unauthorized
| 403 // forbidden
| 404 // not found
| 404 // not found
| 405 // method not allowed
| 409 // conflict
| 500; // internal server error;
};
/**
* A custom error class to normalize the error handling in our app.
*/
export class AppError extends Error {
readonly cause: FailureReason["cause"];
readonly context: FailureReason["context"];
readonly name: FailureReason["name"];
readonly status: FailureReason["status"];
readonly traceId: FailureReason["traceId"];
constructor({
message,
status,
cause = null,
context,
name = "Unknown 🐞",
traceId,
}: FailureReason) {
super();
this.name = name;
this.message = message;
this.status = isLikeAppError(cause)
? status || cause.status || 500
: status || 500;
this.cause = cause;
this.context = context;
// 💡 Useful in case we want to give the user a way to report the error to us.
// We can use this ID to find the error in our logs.
this.traceId = traceId || createId();
}
}
/**
* Provide a condition and if that condition is falsy, this throws an error
* with the given message.
*
* inspired by invariant from 'tiny-invariant' except will still include the
* message in production.
*
* @example
* invariant(typeof value === 'string', `value must be a string`)
*
* @param condition The condition to check
* @param message The message to throw (or a callback to generate the message)
* @param responseInit Additional response init options if a response is thrown
*
* @throws {Error} if condition is falsy
*
* @credit https://github.com/epicweb-dev/epic-stack/blob/f04220665ba884d74a260823dab2a25ef21adb9c/app/utils/misc.ts
*/
export function invariant(
condition: any,
message: string | (() => string),
options?: Partial<Pick<AppError, "context" | "name">>,
): asserts condition {
if (!condition) {
throw new AppError({
name: options?.name || "Invariant violation 👻",
message: typeof message === "function" ? message() : message,
context: options?.context,
});
}
}
/**
* This helper function is used to check if an error is an instance of `AppError` or an object that looks like an `AppError`.
*/
function isLikeAppError(cause: unknown): cause is AppError {
return (
cause instanceof AppError ||
(typeof cause === "object" &&
cause !== null &&
"name" in cause &&
cause.name !== "Error" &&
"message" in cause)
);
}
export function coalesceError(cause: unknown) {
if (isLikeAppError(cause)) {
return new AppError(cause); // copy the original error and fill in the maybe missing fields like status or traceId
}
// 🤷‍♂️ We don't know what this error is, so we create a new default one.
return new AppError({
name: "Unknown 🐞",
message: "Sorry, something went wrong.",
cause,
});
}
type Options<Context> = {
context?: Context;
message?: string;
};
export function badFormSubmission<Submission, Context>(
submission: Submission,
{ context, message }: Options<Context> = {},
) {
return new AppError({
name: "FormData validation 👾",
message: message || "The form submission is invalid.",
context: { ...context, submission } as Context & {
submission: Submission;
},
status: 400,
});
}
import pino from "pino";
import { AppError } from "./error.server";
const NODE_ENV = process.env.NODE_ENV;
function serializeError<E extends Error>(error: E): Error {
if (!(error.cause instanceof Error)) {
return {
...error,
stack: error.stack,
};
}
return {
...error,
cause: serializeError(error.cause),
stack: error.stack,
};
}
const logger = pino({
level: "debug",
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
serializers: {
err: (cause) => {
if (!(cause instanceof AppError)) {
return pino.stdSerializers.err(cause);
}
return serializeError(cause);
},
},
});
/**
* A simple logger that can be used to log messages in the console.
*
* You could interface with a logging service like Sentry or LogRocket here.
*/
export class Logger {
static dev(...args: unknown[]) {
if (NODE_ENV === "development") {
logger.debug(args);
}
}
static devError(...args: unknown[]) {
if (NODE_ENV === "development") {
logger.error(args);
}
}
static log(...args: unknown[]) {
logger.info(args);
}
static warn(...args: unknown[]) {
logger.warn(args);
}
static info(...args: unknown[]) {
logger.info(args);
}
static error(error: unknown) {
logger.error(error);
}
}
/**
* A simple logger to log the time it takes to process a request.
* @example
* // In loader or action
* const logger = new RequestTimeLogger("root loader");
* // do some work
* logger.log(); // logs "root loader took 100ms"
*/
export class RequestTimeLogger {
private readonly start: number;
private readonly label: string;
constructor(label: string) {
this.start = Date.now();
this.label = label;
}
log() {
const end = Date.now();
const duration = end - this.start;
Logger.log(`${this.label} took ${duration}ms`);
}
}
import { type ResponseInit, defer, json, redirect } from "@remix-run/node";
import { AppError } from "./error.server";
import { Logger } from "./logger";
export type CookieHeader = { "Set-Cookie": string };
/**
* @param authHeader The serialized auth session as a cookie header. Set **null** for unprotected routes.
*/
type ExtendedResponseInit = ResponseInit & {
authHeader: CookieHeader | null | undefined;
status?: AppError["status"];
};
function makeResponseInit({
authHeader,
headers,
...init
}: ExtendedResponseInit) {
return { ...init, headers: combineHeaders(headers, authHeader) };
}
type DataPayload = Record<string, unknown>;
function normalizeDataPayload<Payload extends DataPayload>(payload: Payload) {
return { ...payload } as Readonly<Payload> & {
error?: null;
}; // shenanigans to make sure we don't have an error key in type inference ...
}
export type ErrorPayload = Partial<AppError> &
Required<Pick<AppError, "message">>;
// TODO: delete this type if not used
type InferContextType<Payload extends ErrorPayload> =
Payload["context"] extends AppError["context"]
? Payload["context"] extends undefined
? null
: Payload["context"]
: null;
function normalizeErrorPayload<Payload extends ErrorPayload>(payload: Payload) {
const raw = new AppError({
name: "Unknown 🐞",
...payload,
});
return {
normalized: {
error: {
message: raw.message,
context: raw.context,
traceId: raw.traceId,
},
},
raw,
};
}
export type ErrorResponse = ReturnType<
typeof normalizeErrorPayload
>["normalized"];
/**
* This is a tiny helper to normalize `json` responses.
*
* It also forces us to provide `{ authHeader }` (or `{ authHeader: null }` for unprotected routes) as second argument to not forget to handle it.
*
* It can be cumbersome to type, but it's worth it to avoid forgetting to handle authSession.
*/
export const response = {
/**
* When we want to return a response. Pairs with `response.error` to infer loader & action return types.
*
* @param init.authHeader The serialized auth session as a cookie header. Set **null** for unprotected routes.
*/
ok: <Payload extends DataPayload>(
payload: Payload,
init: ExtendedResponseInit,
) => json(normalizeDataPayload(payload), makeResponseInit(init)),
/**
* When we want to return or throw an error response. Pairs with `response.ok` and `response.defer` to infer loader & action return types.
*
* **With `response.defer`, use it only in the case you want to throw an error response.**
*
* @param init.authHeader The serialized auth session as a cookie header. Set **null** for unprotected routes.
*/
error: <Payload extends ErrorPayload>(
payload: Payload,
init: ExtendedResponseInit,
) => {
const { normalized, raw } = normalizeErrorPayload(payload);
Logger.error(raw);
return json(
normalized,
makeResponseInit({ ...init, status: raw.status }),
);
},
defer: <Payload extends DataPayload>(
payload: Payload,
init: ExtendedResponseInit,
) => defer(normalizeDataPayload(payload), makeResponseInit(init)),
/**
* When we want to return a deferred error response.
*
* Works only with `response.defer`.
*
* It should only be used when we want to **return a deferred response.**
*
* **Could not be thrown.** If you want to throw an error response, use `response.error` instead.
*
* @param init.authHeader The serialized auth session as a cookie header. Set **null** for unprotected routes.
*/
deferError: <Payload extends ErrorPayload>(
payload: Payload,
init: ExtendedResponseInit,
) => {
const { normalized, raw } = normalizeErrorPayload(payload);
Logger.error(raw);
return defer(
normalized,
makeResponseInit({ ...init, status: raw.status }),
);
},
/**
* @param init.authHeader The serialized auth session as a cookie header. Set **null** for unprotected routes.
*/
redirect: (url: string, init: ExtendedResponseInit) =>
redirect(url, makeResponseInit(init)),
};
/**
* Combine multiple header objects into one (uses append so headers are not overridden)
*
* @credit https://github.com/epicweb-dev/epic-stack/blob/5f1a9960b0ca46394bdf1fe76dee0b7382502d9e/app/utils/misc.ts#L44
*/
export function combineHeaders(
...headers: Array<ResponseInit["headers"] | null>
) {
const combined = new Headers();
for (const header of headers) {
if (!header) continue;
for (const [key, value] of new Headers(header).entries()) {
combined.append(key, value);
}
}
return combined;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment