Toying with remix and effect to get some fancy errors reporting using effect-errors.
We basically need two things on remix to achieve our goal:
- A custom remix loader accepting an effect and throwing effect errors details.
- An Error boundary to display that information if an error occurs.
importtype{LoaderFunctionArgs}from'@remix-run/server-runtime';import{Effect,pipe}from'effect';import{collectErrorDetails}from'./logic/collect-error-details';import{remixThrow}from'./logic/remix-throw';exportconsteffectLoader=<A,E>(effect: (args: LoaderFunctionArgs)=>Effect.Effect<A,E>)=>async(args: LoaderFunctionArgs)=>awaitEffect.runPromise(pipe(effect(args),Effect.map((data)=>({_tag: 'success'asconst, data })),Effect.sandbox,Effect.catchAll(collectErrorDetails),),).then(remixThrow);If the effect fails, we retrieve errors data and related code:
- In dev mode, effect-errors will use sourcemaps to extract code excerpts related to the error.
- In production however, we must fetch the map file (uploaded in our example on cloudflare R2), and read it to extract sources.
exportconstcollectErrorDetails=<E>(cause: Cause<E>)=>pipe(Effect.gen(function*(){// Serverside loggingconsterrorsText=prettyPrint(cause,{stripCwd: false});console.error(errorsText);const{ errors }=yield*captureErrors(cause,{});if(errors.every((e)=>e.location!==undefined)){// Fetch map file and resolve sourcemaps ...consterrorsWithSources=yield*getErrorSourcesFromMapFile(errors);returnyield*Effect.succeed({_tag: 'effect-post-mapped-errors'asconst,errors: errorsWithSources,});}// in Dev mode, sources are resolved by effect-errorsreturnyield*Effect.succeed({_tag: 'effect-natively-mapped-errors'asconst,
errors,});}),Effect.scoped,Effect.provide(FetchHttpClient.layer),Effect.withSpan('collect-error-details'),);We need to pipe on the promise because remix expects us to throw a json function result from the loader for errors:
import{json}from'@remix-run/server-runtime';import{Match}from'effect';importtype{EffectLoaderError,EffectLoaderSuccess,}from'../types/effect-loader.types';typeRemixThrowInput<A>=EffectLoaderSuccess<A>|EffectLoaderError;consteffectHasSucceeded=<A>(p: RemixThrowInput<A>,): p is EffectLoaderSuccess<A>=>p._tag==='success';exportconstremixThrow=<A>(input: RemixThrowInput<A>)=>Match.value(input).pipe(Match.when(effectHasSucceeded,({ data })=>data),Match.orElse((data)=>{throwjson(data,{status: 500});}),);First, let's create a hook to get errors data:
import{isRouteErrorResponse,useLocation,useRouteError,}from'@remix-run/react';importtype{EffectNativelyMappedErrors,EffectPostMappedErrors,}from'@server/loader/types/effect-loader.types';import{isUnknownAnEffectError}from'./logic/is-uknown-an-effect-error.logic';import{mapEffectErrorTypes}from'./logic/map-effect-error-types';exporttypeEffectPostMappedErrorsWithPath=EffectPostMappedErrors&{path: string;};exporttypeEffectNativelyMappedErrorsWithPath=EffectNativelyMappedErrors&{path: string;};exporttypeErrorsDetails=|{_tag: 'route'|'error'|'unknown';path: string;errors: {message: string;}[];}|EffectPostMappedErrorsWithPath|EffectNativelyMappedErrorsWithPath;exportconstuseErrorDetails=(): ErrorsDetails=>{const{ pathname }=useLocation();consterror=useRouteError();if(isUnknownAnEffectError(error)){returnmapEffectErrorTypes(error,pathname);}constisRoute=isRouteErrorResponse(error);if(isRoute){return{_tag: 'route'asconst,path: pathname,errors: [{message: `${error.statusText}`,},],};}if(errorinstanceofError){return{_tag: 'error'asconst,path: pathname,errors: [error],};}return{_tag: 'unknown'asconst,path: pathname,errors: [{message: 'Unknown Error'}],};};We can then focus on displaying our errors data...
import{AppErrors}from'./children/app-errors';import{Summary}from'./children/summary';import{errorBoundaryStyles}from'./error-boundary.styles';import{useErrorDetails}from'./hooks/use-error-details';exportconstErrorBoundary=()=>{constcss=errorBoundaryStyles();constdata=useErrorDetails();return(<divclassName={css.root}><Summary{...data}/><AppErrors{...data}/></div>);};