Created
August 24, 2023 17:01
-
-
Save rphlmr/11fd3be454a23491478262a775f5367c to your computer and use it in GitHub Desktop.
Remix +
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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