Skip to content

Instantly share code, notes, and snippets.

@xesrevinu
Last active July 28, 2024 02:30
Show Gist options
  • Select an option

  • Save xesrevinu/4e41ce0836ba342eb771e41b72b95402 to your computer and use it in GitHub Desktop.

Select an option

Save xesrevinu/4e41ce0836ba342eb771e41b72b95402 to your computer and use it in GitHub Desktop.
remix effect
export class InternalServerError extends Schema.TaggedError<InternalServerError>()("InternalServerError", {
message: Schema.string,
stack: Schema.optional(Schema.string),
}) {
readonly code = 500
encode() {
return Schema.encode(InternalServerError)(this)
}
decode() {
return Schema.decode(InternalServerError)(this)
}
}
export class AppError extends Schema.TaggedError<AppError>()("AppError", {
message: Schema.string,
stack: Schema.optional(Schema.string),
}) {
readonly code = 400
encode() {
return Schema.encode(AppError)(this)
}
decode() {
return Schema.decode(AppError)(this)
}
}
export class BadRequestError extends Schema.TaggedError<BadRequestError>()("BadRequestError", {
message: Schema.string,
stack: Schema.optional(Schema.string),
}) {
readonly code = 400
encode() {
return Schema.encode(BadRequestError)(this)
}
decode() {
return Schema.decode(BadRequestError)(this)
}
}
export class UnauthorizedError extends Schema.TaggedError<UnauthorizedError>()("UnauthorizedError", {
message: Schema.string,
stack: Schema.optional(Schema.string),
}) {
readonly code = 401
encode() {
return Schema.encode(UnauthorizedError)(this)
}
decode() {
return Schema.decode(UnauthorizedError)(this)
}
}
export class ForbiddenError extends Schema.TaggedError<ForbiddenError>()("ForbiddenError", {
message: Schema.string,
stack: Schema.optional(Schema.string),
}) {
readonly code = 403
encode() {
return Schema.encode(ForbiddenError)(this)
}
decode() {
return Schema.decode(ForbiddenError)(this)
}
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError", {
message: Schema.string,
stack: Schema.optional(Schema.string),
}) {
readonly code = 404
encode() {
return Schema.encode(NotFoundError)(this)
}
decode() {
return Schema.decode(NotFoundError)(this)
}
}
// Application error can be returned to the user through a return value.
export type RemixAppError =
| InternalServerError
| BadRequestError
| UnauthorizedError
| ForbiddenError
| NotFoundError
| RatelimitError
| AppError
// Default handling of errors, not handled on the user side
export type RemixInternalError =
| InternalServerError
| BadRequestError
| UnauthorizedError
| ForbiddenError
| NotFoundError
| RatelimitError
export const RemixInternalErrorTags = [
InternalServerError.identifier,
BadRequestError.identifier,
UnauthorizedError.identifier,
ForbiddenError.identifier,
NotFoundError.identifier,
RatelimitError.identifier,
]
export const isRemixInternalError = (error: any): error is RemixInternalError => {
return RemixInternalErrorTags.includes(error._tag)
}
export class RemixFormDataParseError extends Data.TaggedError("RemixFormDataParseError")<{
error: ParseResult.ParseError
}> {
get message() {
return this.toString()
}
toString() {
return TreeFormatter.formatError(this.error)
}
toJSON() {
return {
_tag: "RemixFormDataParseError",
message: this.toString(),
detail: this.error.stack,
}
}
}
export class RemixBodyParseError extends Data.TaggedError("RemixBodyParseError")<{
error: ParseResult.ParseError
}> {
get message() {
return this.toString()
}
toString() {
return TreeFormatter.formatError(this.error)
}
toJSON() {
return {
_tag: "RemixFormDataParseError",
message: this.toString(),
detail: this.error.stack,
}
}
}
export class RemixSearchParamsParseError extends Data.TaggedError("RemixSearchParamsParseError")<{
error: ParseResult.ParseError
}> {
get message() {
return this.toString()
}
toString() {
return TreeFormatter.formatError(this.error)
}
toJSON() {
return {
_tag: "RemixSearchParamsParseError",
message: this.toString(),
detail: this.error.stack,
}
}
}
export class RemixParamsParseError extends Data.TaggedError("RemixParamsParseError")<{
error: ParseResult.ParseError
}> {
get message() {
return this.toString()
}
toString() {
return TreeFormatter.formatError(this.error)
}
toJSON() {
return {
_tag: "RemixParamsParseError",
message: this.toString(),
detail: this.error.stack,
}
}
}
export class RemixRequestError extends Data.TaggedClass("RemixRequestError")<{
error:
| InternalServerError
| NotFoundError
| RemixFormDataParseError
| RemixSearchParamsParseError
| RemixBodyParseError
| RemixParamsParseError
| RatelimitError
headers: HeadersInit
}> {}
export type RemixDataError =
| RemixSearchParamsParseError
| RemixFormDataParseError
| RemixBodyParseError
| RemixParamsParseError
| ParseResult.ParseError
export class RemixRedirect extends Data.TaggedClass("RemixRedirect")<{
response: TypedResponse
headers: HeadersInit
}> {
get url() {
return this.response.headers.get("Location")
}
toString() {
return `RemixRedirect: ${this.response.status} ${this.url}`
}
toJSON() {
return {
_tag: "RemixRedirect",
url: this.url,
status: this.response.status,
}
}
}
export type RemixResult<A, E> = RemixResultSuccess<A> | RemixResultFailure<E>
export interface RemixResultSuccess<A> {
_tag: "RemixResultSuccess"
readonly success: true
readonly result: A
headers: HeadersInit
}
export interface RemixResultFailure<E> {
_tag: "RemixResultFailure"
readonly success: false
readonly error: E
headers: HeadersInit
}
export const RemixResult = {
Success: <A, E>(_: { result: A; headers: HeadersInit }): RemixResult<A, E> => ({
_tag: "RemixResultSuccess",
success: true,
result: _.result,
headers: _.headers,
}),
Failure: <A, E>(_: { error: E; headers: HeadersInit }): RemixResult<A, E> => ({
_tag: "RemixResultFailure",
success: false,
error: _.error,
headers: _.headers,
}),
}
export interface RemixDataSuccess<A> {
readonly success: true
readonly result: A
}
export interface RemixDataFailure<E> {
readonly success: false
readonly error: E
}
export type RemixData<A, E> = RemixDataSuccess<A> | RemixDataFailure<E>
const assignHeaders = (headers: HeadersInit | Record<string, string | number>, res: Response) => {
Object.entries(headers).forEach(([key, value]) => {
res.headers.set(key, value)
})
}
export const toResponse =
(init?: number | RequestInit | undefined) =>
<A, E>(exit: RemixRedirect | RemixResult<A, E>): TypedResponse<RemixData<A, E>> => {
const value = exit
if (value._tag === "RemixRedirect") {
assignHeaders(value.headers, value.response)
throw value.response
}
if (value._tag === "RemixResultFailure") {
const error = value.error as RemixAppError
if (error._tag === "InternalServerError") {
const encodeError = Effect.runSync(error.encode())
const response = remixJson(stripError(encodeError), {
status: error.code,
headers: value.headers,
})
throw response
}
}
if (value._tag === "RemixResultFailure") {
const error = value.error as RemixAppError
const encodeError = Effect.runSync(error.encode() as Effect.Effect<E, never, never>)
const data: RemixData<A, E> = {
success: false,
error: stripError(encodeError) as E,
}
const userInit = typeof init === "number" ? { status: init } : init ? { ...init } : {}
const status = userInit.status || (value.error as any).code || 400
const response = remixJson(data, {
...userInit,
status: status,
})
assignHeaders(value.headers, response)
return response
}
const data: RemixData<A, E> = {
success: true,
result: value.result,
}
const response = remixJson(data, init)
assignHeaders(value.headers, response)
return response
}
export type ExcludeServerError<T> = T extends RemixDataError ? BadRequestError : T
export type ExcludeInternalError<T> = T extends RemixInternalError ? never : T
export interface RequestContext {
readonly request: Request
readonly params: Params<string>
readonly context: AppLoadContext
}
export const RequestContext = Context.GenericTag<RequestContext>("@lib/RequestContext")
const withRequestContext = <E, A>(effect: (context: RequestContext) => Effect.Effect<A, E>) =>
pipe(
Effect.context<never>(),
Effect.map((ctx) => Context.get(ctx as Context.Context<RequestContext>, RequestContext)),
Effect.andThen(effect),
)
export const request = withRequestContext((context) => Effect.succeed(context.request))
export const context = withRequestContext((context) => Effect.succeed(context.context))
export const getFormDataEntries = withRequestContext(
(context): Effect.Effect<Record<string, any>, RemixFormDataParseError, never> =>
pipe(
Effect.tryPromise({
try: () => context.request.formData(),
catch: (e: unknown) =>
new RemixFormDataParseError({
error: new ParseResult.ParseError({
error: new ParseResult.Forbidden(
Schema.unknown.ast,
"",
`Unable to parse the form data: ${(e as Error).message}`,
),
}),
}),
}),
Effect.map((formData) => Object.fromEntries(formData as any)),
Effect.withSpan("remix.getFormDataEntries"),
),
)
export const getFormData = <A, I>(schema: Schema.Schema<A, I>): Effect.Effect<A, RemixFormDataParseError, never> =>
pipe(
getFormDataEntries,
Effect.andThen((entries) => Schema.decodeUnknown(schema)(entries)),
Effect.catchTag("ParseError", (_) => Effect.fail(new RemixFormDataParseError({ error: _ }))),
Effect.withSpan("remix.decodeFormData"),
)
export const getBody = <A, I>(schema: Schema.Schema<A, I>): Effect.Effect<A, RemixBodyParseError, never> =>
withRequestContext((context) =>
pipe(
Effect.tryPromise({
try: () => context.request.json(),
catch: (e: unknown) =>
new RemixBodyParseError({
error: new ParseResult.ParseError({
error: new ParseResult.Forbidden(
schema.ast,
"",
`Unable to parse the request body as JSON: ${(e as Error).message}`,
),
}),
}),
}),
Effect.flatMap((json) => Schema.decodeUnknown(schema)(json)),
Effect.catchTag("ParseError", (_) => Effect.fail(new RemixBodyParseError({ error: _ }))),
Effect.withSpan("remix.decodeBody"),
),
)
export const getSearchParams = <A, I>(
schema: Schema.Schema<A, I>,
): Effect.Effect<A, RemixSearchParamsParseError, never> =>
withRequestContext((context) =>
pipe(
Effect.try({
try: () => Object.fromEntries(new URL(context.request.url).searchParams.entries()),
catch: (e: unknown) =>
new RemixSearchParamsParseError({
error: new ParseResult.ParseError({
error: new ParseResult.Forbidden(
schema.ast,
"",
`Unable to parse the search params: ${(e as Error).message}`,
),
}),
}),
}),
Effect.flatMap((_) => Schema.decodeUnknown(schema)(_)),
Effect.catchTag("ParseError", (_) => Effect.fail(new RemixSearchParamsParseError({ error: _ }))),
Effect.withSpan("remix.decodeSearchParams"),
),
)
export const getParams = <I, A>(schema: Schema.Schema<A, I>): Effect.Effect<A, RemixParamsParseError, never> =>
withRequestContext((context) =>
pipe(
Effect.succeed(context.params),
Effect.flatMap((_) => Schema.decodeUnknown(schema)(_)),
Effect.mapError((_) => new RemixParamsParseError({ error: _ })),
Effect.withSpan("remix.decodeParams"),
),
)
export const Cookies = {
string: withRequestContext(
(context): Effect.Effect<string | undefined, never, never> =>
pipe(
Effect.sync(() => context.request.headers.get("Cookie") || undefined),
Effect.withSpan("remix.cookies"),
),
),
get: (key: string, fallback?: () => string): Effect.Effect<string | undefined, never, never> =>
withRequestContext((context) =>
pipe(
Effect.map(
Effect.sync(() => context.request.headers.get("Cookie")),
(cookie) => {
if (cookie) {
return findCookieByName(key, cookie) || fallback?.()
}
return fallback?.()
},
),
Effect.withSpan("remix.cookies"),
),
),
}
export const redirect = (url: string, init?: number | ResponseInit | undefined): Effect.Effect<void, never, never> => {
return pipe(
Effect.suspend(() =>
Effect.die(
new RemixRedirect({
response: remixRedirect(url, init),
headers: {},
}),
),
),
Effect.zip(
Effect.succeed(0).pipe(
Effect.withSpan("remix.redirect"),
Effect.annotateSpans("url", url),
Effect.annotateSpans("init", init),
),
{
batching: true,
concurrent: true,
},
),
Effect.asUnit,
)
}
export interface RequestLoadContext<E, R> extends AppLoadContext {
waitUntil?: (promise: Promise<void>) => void
passThroughOnException?: () => void
runtime: MangedRuntime.ManagedRuntime<R, never>
}
export interface RequestHandlerConfig {
/**
* The name of the span
*
* @example "remix.effect"
*/
name: string
/**
* Trace span options
*/
spanOptions?:
| {
attributes?: Record<string, unknown> | undefined
links?: ReadonlyArray<SpanLink> | undefined
parent?: ParentSpan | undefined
root?: boolean | undefined
context?: Context.Context<never> | undefined
}
| undefined
/**
* The ratelimit configuration
*/
ratelimit?:
| (() => RatelimitConfig & {
identifier?: "ip" | Effect.Effect<string>
})
| undefined
/**
* Skip the ratelimit check
*/
skipRatelimit?: boolean | undefined
/**
* Skip the tracing
*/
skipTracing?: boolean | undefined
}
export const shouldSkipRatelimit = (request: Request) => {
// "Uptime-Kuma/0.0.0"
const userAgent = request.headers.get("User-Agent") || ""
const skipList = ["Uptime-Kuma"]
const devMode = globalThis.env.NODE_ENV === "development" || globalThis.env.NODE_ENV === "test"
return devMode || skipList.some((_) => userAgent.indexOf(_) !== -1)
}
export const shouldSkipTracing = (request: Request) => {
const userAgent = request.headers.get("User-Agent") || ""
const skipList = ["Uptime-Kuma"]
return skipList.some((_) => userAgent.indexOf(_) !== -1)
}
export type RequestHandlerType = "loader" | "action"
export const createRequestHandler =
(type: RequestHandlerType, config?: RequestHandlerConfig) =>
<A, E, R, E1>(
args: LoaderFunctionArgs | ActionFunctionArgs,
body: Effect.Effect<A, E, R>,
transform: (_: RemixResult<A, E>) => Effect.Effect<RemixResult<A, E>>,
) => {
const ip = args.request.headers.get("X-Forwarded-For") ?? args.request.headers.get("x-real-ip") ?? undefined
const spanName = `remix.${config?.name ? `${config.name}.${type}` : type}`
const logLevel = globalThis.env.logLevel
? LogLevel.fromLiteral(globalThis.env.logLevel)
: globalThis.env.NODE_ENV === "development" || globalThis.env.NODE_ENV === "test"
? LogLevel.All
: LogLevel.Error
let main = body
const headers = {}
if (config?.ratelimit) {
const skipRateLimit = config.skipRatelimit || shouldSkipRatelimit(args.request)
if (!skipRateLimit) {
const { cf } = args.request as unknown as {
cf?: {
city?: string
region?: string
country?: string
}
}
main = pipe(
Effect.sync(() => config.ratelimit!()),
Effect.flatMap((ratelimitConfig) => {
if (ratelimitConfig.identifier && Effect.isEffect(ratelimitConfig.identifier)) {
return ratelimitConfig.identifier.pipe(
Effect.zip(Effect.succeed(ratelimitConfig)),
Effect.withSpan("remix.ratelimit.get-identifier"),
)
}
return Effect.succeed([ip ?? "global", ratelimitConfig] as const)
}),
Effect.flatMap(([identifier, ratelimitConfig]) => {
const ratelimitReq = {
geo: {
city: cf?.city,
country: cf?.country,
region: cf?.region,
ip,
},
}
return pipe(
take(ratelimitConfig),
Effect.flatMap((api) =>
pipe(
api.limit(identifier, ratelimitReq),
Effect.tapBoth({
onFailure: (error) =>
pipe(
Effect.logDebug("ratelimit failed"),
Effect.annotateLogs({
prefix: api.prefix,
identifier,
geo: ratelimitReq.geo,
remaining: error.headers.remaining,
limit: error.headers.limit,
reset: new Date(error.headers.reset).toLocaleString(),
}),
Effect.annotateSpans({
prefix: api.prefix,
identifier,
geo: ratelimitReq.geo,
remaining: error.headers.remaining,
limit: error.headers.limit,
reset: error.headers.reset / 1000,
}),
Effect.tap(() => {
const errorHeaders = error.headers
headers["X-RateLimit-Limit"] = errorHeaders.limit
headers["X-RateLimit-Remaining"] = errorHeaders.remaining
headers["X-RateLimit-Reset"] = errorHeaders.reset / 1000
headers["Retry-After"] = errorHeaders.reset / 1000
}),
),
onSuccess: (res) =>
pipe(
Effect.logDebug("ratelimit pass"),
Effect.annotateLogs({
prefix: api.prefix,
identifier,
geo: ratelimitReq.geo,
remaining: res.remaining,
limit: res.limit,
reset: new Date(res.reset).toLocaleString(),
}),
Effect.annotateSpans({
prefix: api.prefix,
identifier,
geo: ratelimitReq.geo,
remaining: res.remaining,
limit: res.limit,
reset: res.reset / 1000,
}),
Effect.tap(() => {
headers["X-RateLimit-Limit"] = res.limit
headers["X-RateLimit-Remaining"] = res.remaining
headers["X-RateLimit-Reset"] = res.reset / 1000
headers["Retry-After"] = res.reset / 1000
}),
),
}),
Effect.catchTag("RatelimitError", (e) => {
// when the ratelimit service is down, let the request pass
if (e.reason === "UnknownError") {
return Effect.unit
}
return Effect.fail(e as E)
}),
),
),
)
}),
Effect.withSpan("remix.ratelimit.check"),
Effect.flatMap(() => body),
)
}
}
let requestHandler = Effect.flatMap(main, (_) => transform(RemixResult.Success({ result: _, headers })))
if (config?.name) {
requestHandler = pipe(requestHandler, Effect.withLogSpan(spanName), Effect.withSpan(spanName, config.spanOptions))
}
const skipTracing = config?.skipTracing || shouldSkipTracing(args.request)
requestHandler = Effect.withTracerEnabled(withParentRequestTrace(args.request, requestHandler), !skipTracing)
const program = pipe(
Effect.sandbox(requestHandler),
Effect.catchAll((e) => {
const cause = e as Cause.Cause<RemixRequestError["error"]>
if (cause._tag === "Die") {
if (cause.defect && Cause.isRuntimeException(cause.defect)) {
const error = new InternalServerError({
message: `RuntimeException: ${cause.defect.message}`,
stack: cause.defect.stack,
})
return Effect.succeed(
RemixResult.Failure({
error,
headers,
}),
) as Effect.Effect<RemixRedirect | RemixResult<A, E1>>
}
if (cause.defect && cause.defect instanceof RemixRedirect) {
Object.assign(cause.defect.headers, headers)
return Effect.succeed(cause.defect) as Effect.Effect<RemixRedirect | RemixResult<A, E1>>
}
if (cause.defect && cause.defect instanceof Error) {
const error = new InternalServerError({
message: `${cause.defect.name}: ${cause.defect.message}`,
stack: cause.defect.stack,
})
return Effect.succeed(
RemixResult.Failure({
error,
headers,
}),
) as Effect.Effect<RemixRedirect | RemixResult<A, E1>>
}
}
// Remix Request Error -> Remix App Error
if (cause._tag === "Fail") {
const error = cause.error
let appError: RemixAppError | null = null
// Uncatcha parse error
if (error && isParseError(error)) {
appError = new BadRequestError({
message: (error as ParseResult.ParseError).message,
stack: (error as ParseResult.ParseError).stack,
})
} else if (
error._tag === "RemixBodyParseError" ||
error._tag === "RemixFormDataParseError" ||
error._tag === "RemixSearchParamsParseError" ||
error._tag === "RemixParamsParseError"
) {
appError = new BadRequestError({
message: error.message,
stack: error.stack
})
} else if (error._tag) {
const casueError = error as
| NotFoundError
| RatelimitError
| ForbiddenError
| UnauthorizedError
| InternalServerError
appError = casueError
}
if (!appError) {
appError = new InternalServerError({
message: String(cause.error.message),
stack: cause.error.stack,
})
}
return Effect.succeed(
RemixResult.Failure({
error: appError,
headers,
}),
) as Effect.Effect<RemixRedirect | RemixResult<A, E1>>
}
const error = new InternalServerError({
message: Cause.pretty(cause) || "Unexpected error",
})
return Effect.succeed(
RemixResult.Failure({
error,
headers,
}),
) as Effect.Effect<RemixRedirect | RemixResult<A, E1>>
}),
Effect.provideService(
RequestContext,
RequestContext.of({ request: args.request, params: args.params, context: args.context }),
),
Effect.tapDefect((cause) => {
if (Cause.isInterrupted(cause)) {
return Effect.unit
}
const message = Cause.pretty(cause)
if (message.indexOf("AggregateError") !== -1) {
return Effect.unit
}
return Effect.logError(message)
}),
Logger.withMinimumLogLevel(logLevel),
)
return program as Effect.Effect<RemixRedirect | RemixResult<A, E1>, never>
}
type BuiltInR = KVStorage | Database | I18n
const make =
(type: RequestHandlerType) =>
<A, E, R extends BuiltInR>(body: Effect.Effect<A, E, R>, config?: RequestHandlerConfig | undefined) => {
const handler = createRequestHandler(type, config)
const json_ =
(init?: number | ResponseInit | undefined) =>
(args: LoaderFunctionArgs): Promise<TypedResponse<RemixData<A, ExcludeServerError<E>>>> => {
const context = args.context as RequestLoadContext<E, R>
const run = handler<A, E, R, ExcludeServerError<E>>(args, body, (result) => Effect.succeed(result))
return context.runtime.runPromise(run, { signal: args.request.signal }).then(toResponse(init))
}
const schema =
<A2>(schema: Schema.Schema<A, A2>, init?: number | RequestInit | undefined) =>
(args: LoaderFunctionArgs): Promise<TypedResponse<RemixData<A2, ExcludeServerError<E>>>> => {
const context = args.context as RequestLoadContext<E, R>
const encode = Schema.encodeUnknown(schema)
const run = handler<A, E, R, ExcludeServerError<E>>(args, body, (result) => {
if (result._tag === "RemixResultFailure") {
return Effect.succeed(result) as Effect.Effect<RemixResult<A, E>>
}
return pipe(
encode(result.result),
Effect.map(
(res) =>
RemixResult.Success({
result: res,
headers: result.headers,
}) as RemixResult<A, E>,
),
Effect.catchAll((error) =>
Effect.succeed(
RemixResult.Failure({
error: new InternalServerError({
message: `Failed to encode response: ${error.message}`,
stack: error.stack,
}),
headers: result.headers,
}) as RemixResult<A, E>,
),
),
Effect.withSpan(`remix.${type}.schema`),
)
})
return context.runtime.runPromise(run, { signal: args.request.signal }).then(toResponse(init)) as Promise<
TypedResponse<RemixData<A2, ExcludeServerError<E>>>
>
}
const effect = (
args: LoaderFunctionArgs,
): Effect.Effect<RemixRedirect | RemixResult<A, ExcludeServerError<E>>, never, never> => {
const context = args.context as RequestLoadContext<E, R>
const run = handler<A, E, R, ExcludeServerError<E>>(args, body, (_) => Effect.succeed(_))
return Effect.promise((s) => context.runtime.runPromise(run, { signal: s }))
}
return { json: json_, schema, effect } as const
}
export const loader = make("loader")
export const action = make("action")
export const witTestRequest = (context: Partial<RequestContext>) => {
return Effect.provideService(
RequestContext,
RequestContext.of({
request: new Request("http://localhost", {
method: "GET",
}),
context: {},
params: {},
...context,
}),
)
}
export type ReduceError<T extends RemixData<any, any>> = T extends RemixDataFailure<infer E>
? E extends never
? never
: T
: T
export type ExtractSuccess<T> = T extends RemixDataSuccess<infer A> ? A : never
export type ExtractFailure<T> = T extends RemixDataFailure<infer E> ? E : never
export type SafeLoaderData<T extends (args: LoaderFunctionArgs) => Promise<RemixData<any, any>>> =
ReturnType<T> extends Promise<RemixData<infer A, infer E>> ? RemixData<A, ExcludeInternalError<E>> : never
export const useRouteLoaderData = useRouteLoaderData_ as <
T extends (args: LoaderFunctionArgs) => Promise<RemixData<any, any>>,
>(
routeId: string,
) => ReduceError<SafeLoaderData<T>>
export function useLoaderData<T extends (args: LoaderFunctionArgs) => Promise<RemixData<any, any>>>() {
const data = useLoaderData_() as ReduceError<SafeLoaderData<T>>
if (!data.success && isRemixInternalError(data.error as RemixInternalError)) {
throw data.error
}
return data
}
export function useActionData<T extends (args: ActionFunctionArgs) => Promise<RemixData<any, any>>>(options: {
onFailure?: (error: ExtractFailure<ReduceError<SafeLoaderData<T>>>) => void
onInternalError?: (error: RemixInternalError) => void
}) {
const data = useActionData_() as ReduceError<SafeLoaderData<T>>
const onFailure = useStableHandler(options.onFailure || (() => {}))
const onInternalError = useStableHandler(options.onInternalError || (() => {}))
useEffect(() => {
if (!data.success) {
const error = data.error
if (isRemixInternalError(error)) {
if (options.onInternalError) {
onInternalError(error)
return
}
toast.error(formatInternalError(error))
return
}
}
}, [data, onFailure, options.onInternalError])
return data
}
export function useFetcherData<T extends (args: LoaderFunctionArgs) => Promise<RemixData<any, any>>>(options: {
key?: string
onSuccess?: (result: ExtractSuccess<SafeLoaderData<T>>) => void
onFailure?: (error: ExtractFailure<ReduceError<SafeLoaderData<T>>>) => void
onInternalError?: (error: RemixInternalError) => void
}) {
const fetcher = useFetcher(options.key ? { key: options.key } : {}) as FetcherWithComponents<SafeLoaderData<T>>
const onFailure = useStableHandler(options.onFailure || (() => {}))
const onSuccess = useStableHandler(options.onSuccess || (() => {}))
const onInternalError = useStableHandler(options.onInternalError || (() => {}))
const data = fetcher.data as RemixData<any, any>
useEffect(() => {
if (fetcher.state === "idle") {
if (!data) {
return
}
if (data.success) {
onSuccess(data.result)
return
}
if (isRemixInternalError(data.error as RemixInternalError)) {
if (options.onInternalError) {
onInternalError(data.error as RemixInternalError)
return
}
toast.error(formatInternalError(data.error))
return
}
onFailure(data.error)
return
}
}, [fetcher.state, data, onFailure, onSuccess, onInternalError])
return fetcher
}
const formatInternalError = (error: RemixInternalError) => {
return error._tag + (error.message ? `: ${error.message}` : "")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment