react-router-effect 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -11
- package/dist/index.d.mts +57 -21
- package/dist/index.mjs +38 -32
- package/package.json +2 -2
- package/src/factory.ts +138 -89
package/README.md
CHANGED
|
@@ -39,18 +39,23 @@ A loader/action effect can short-circuit in three ways, via the `Respond` helper
|
|
|
39
39
|
| `Respond.throw(value, init?)` | **throw** | rejects with `data(value, init)` → error boundary |
|
|
40
40
|
| `Respond.redirect(url, init?)` | **throw** | rejects with a redirect `Response` |
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
You declare your app's **domain errors** as a type argument to the factory, and may register a
|
|
43
|
+
handler per domain error. A handler _remaps_ an error by returning either:
|
|
44
44
|
|
|
45
45
|
- a library route error — `Respond.early(...)` / `Respond.throw(...)` / `Respond.redirect(...)`; or
|
|
46
46
|
- an `Effect` — `Effect.succeed(value)` to **recover** with `value`, or `Effect.fail(response)`
|
|
47
47
|
to **throw** a `Response` / `DataWithResponseInit`.
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
A **declared domain error** may be left unhandled:
|
|
50
50
|
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
-
|
|
51
|
+
- if it implements [`HttpServerRespondable`](https://effect.website) it's rendered automatically
|
|
52
|
+
from its own response;
|
|
53
|
+
- otherwise it falls through to a **500**.
|
|
54
|
+
|
|
55
|
+
**Any other error** a route consumes — a service-specific error that isn't a declared domain
|
|
56
|
+
error — **must be handled** in the loader/action (caught or mapped), or `makeLoader`/`makeAction`
|
|
57
|
+
fails to type-check. This gives app-wide defaults for declared errors while enforcing explicit
|
|
58
|
+
handling of feature/service-specific ones.
|
|
54
59
|
|
|
55
60
|
## Usage
|
|
56
61
|
|
|
@@ -63,8 +68,14 @@ import { makeLoaderOrActionFactory, Respond as baseRespond } from "react-router-
|
|
|
63
68
|
|
|
64
69
|
class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
|
|
65
70
|
class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
|
|
71
|
+
class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
|
|
72
|
+
|
|
73
|
+
// Declare every error your app handles app-wide. `DbError` has no handler below,
|
|
74
|
+
// so it falls through to the 500 default.
|
|
75
|
+
type DomainErrors = FormError | BadInputError | DbError;
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
// Curried: pin the domain errors, then the handler types are inferred.
|
|
78
|
+
export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
68
79
|
errorHandlers: {
|
|
69
80
|
// recover: short-circuit and hand the reply to the component
|
|
70
81
|
FormError: (error: FormError) => baseRespond.early({ reply: error.reply }),
|
|
@@ -81,8 +92,8 @@ export const Respond = {
|
|
|
81
92
|
};
|
|
82
93
|
```
|
|
83
94
|
|
|
84
|
-
> **Annotate each handler's parameter.**
|
|
85
|
-
> types are derived from the handler map's parameter and return types.
|
|
95
|
+
> **Annotate each handler's parameter.** Handlers must be keyed by a declared domain error, and
|
|
96
|
+
> the precise recover types are derived from the handler map's parameter and return types.
|
|
86
97
|
|
|
87
98
|
### 2. Write loaders/actions as effects
|
|
88
99
|
|
|
@@ -131,8 +142,9 @@ A registered handler, if present, still takes precedence over the error's own re
|
|
|
131
142
|
|
|
132
143
|
## API
|
|
133
144
|
|
|
134
|
-
- **`makeLoaderOrActionFactory({ errorHandlers })`** → `{ makeLoader, makeAction
|
|
135
|
-
(
|
|
145
|
+
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers })`** → `{ makeLoader, makeAction }`
|
|
146
|
+
(both are the same wrapper). A non-domain error left in a loader/action's error channel is a
|
|
147
|
+
compile error.
|
|
136
148
|
- **`Respond`** — `early` (recover), `throw`, `redirect`.
|
|
137
149
|
- **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
|
|
138
150
|
route errors, and **`isRouteError`** to narrow them.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { HttpServerRespondable } from "effect/unstable/http";
|
|
2
3
|
import { ActionFunctionArgs, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } from "react-router";
|
|
3
4
|
|
|
4
5
|
//#region src/errors.d.ts
|
|
@@ -49,6 +50,9 @@ declare const Respond: {
|
|
|
49
50
|
declare const isRouteError: (value: unknown) => value is AnyRouteError;
|
|
50
51
|
//#endregion
|
|
51
52
|
//#region src/factory.d.ts
|
|
53
|
+
type Tagged = {
|
|
54
|
+
readonly _tag: string;
|
|
55
|
+
};
|
|
52
56
|
/**
|
|
53
57
|
* Everything a failure branch rejects with. React Router routes a thrown
|
|
54
58
|
* `DataWithResponseInit` to the boundary as an `ErrorResponse`, and a thrown
|
|
@@ -80,41 +84,73 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_D
|
|
|
80
84
|
* - throwables, redirects and `Effect.fail(...)` contribute nothing.
|
|
81
85
|
*/
|
|
82
86
|
type RecoverOf<Handlers, E> = { [Tag in keyof Handlers]: Handlers[Tag] extends ((error: infer Err) => infer R) ? [Extract<E, Err>] extends [never] ? never : (R extends ReturnableDataError<infer B> ? UNSAFE_DataWithResponseInit<B> : never) | (R extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never) : never }[keyof Handlers];
|
|
83
|
-
/**
|
|
84
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Errors the library deals with on the loader's behalf — so the loader needn't
|
|
89
|
+
* handle them itself:
|
|
90
|
+
* - the app's **declared domain errors** (`DomainError`) — handled by a registered
|
|
91
|
+
* handler, or left to the 500 / auto-respond default;
|
|
92
|
+
* - **library route errors** raised directly via `Respond`;
|
|
93
|
+
* - anything that renders itself via **`HttpServerRespondable`**.
|
|
94
|
+
*/
|
|
95
|
+
type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
|
|
96
|
+
/**
|
|
97
|
+
* What remains in a loader/action's error channel that the library will NOT handle
|
|
98
|
+
* — i.e. service-specific errors the route consumes that aren't declared domain
|
|
99
|
+
* errors. The loader/action must handle these itself (catch or map them).
|
|
100
|
+
*/
|
|
101
|
+
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
102
|
+
/**
|
|
103
|
+
* The shape a handler map must satisfy: an OPTIONAL handler per declared domain
|
|
104
|
+
* error, keyed by its tag, taking that error and returning a library route error
|
|
105
|
+
* or an `Effect` failing with a response. Used only as a validation constraint —
|
|
106
|
+
* the concrete handler types stay precise via the `const Handlers` inference.
|
|
107
|
+
*/
|
|
108
|
+
type ValidHandlers<DomainError extends Tagged> = { [Tag in DomainError["_tag"]]?: (error: Extract<DomainError, {
|
|
109
|
+
readonly _tag: Tag;
|
|
110
|
+
}>) => AnyRouteError | Effect.Effect<unknown, FailureResponse> };
|
|
111
|
+
/**
|
|
112
|
+
* True when every registered handler is keyed by a declared domain error's tag and
|
|
113
|
+
* returns a library route error or a failing `Effect`. A handler for an unknown tag
|
|
114
|
+
* (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
|
|
115
|
+
*/
|
|
116
|
+
type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [DomainError["_tag"]] ? Handlers extends ValidHandlers<DomainError> ? true : false : false;
|
|
85
117
|
/**
|
|
86
118
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
87
119
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
93
|
-
* throw). Recovered values become loader/action data, typed precisely (non-generic).
|
|
120
|
+
* Declare the app's **domain errors** as the type argument, then register an
|
|
121
|
+
* *optional* handler per domain error in `errorHandlers` (**annotate each handler's
|
|
122
|
+
* param**). It's curried so you can pin the domain errors while the handler types
|
|
123
|
+
* are still inferred:
|
|
94
124
|
*
|
|
95
|
-
* Handling is optional: an *unregistered* error is allowed. If it implements
|
|
96
|
-
* `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
|
|
97
|
-
* error boundary as a 500.
|
|
98
|
-
*
|
|
99
|
-
* @example
|
|
100
125
|
* ```ts
|
|
101
|
-
*
|
|
126
|
+
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
127
|
+
*
|
|
128
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
102
129
|
* errorHandlers: {
|
|
103
130
|
* // throw → error boundary
|
|
104
131
|
* MyDomainError: (error: MyDomainError) =>
|
|
105
132
|
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
106
|
-
* //
|
|
107
|
-
* FormError: (error: FormError) => Respond.early({ reply: error.reply }),
|
|
133
|
+
* // DbError has no handler → falls through to the 500 default.
|
|
108
134
|
* },
|
|
109
135
|
* });
|
|
110
136
|
* ```
|
|
137
|
+
*
|
|
138
|
+
* A handler *remaps* the error by returning either a library route error
|
|
139
|
+
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
140
|
+
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
141
|
+
* throw). Recovered values become loader/action data, typed precisely.
|
|
142
|
+
*
|
|
143
|
+
* **Declared domain errors** may be left unhandled: if one implements
|
|
144
|
+
* `HttpServerRespondable` it's rendered automatically, otherwise it falls through
|
|
145
|
+
* to a 500. **Any other error** — a service-specific error the route consumes that
|
|
146
|
+
* isn't a declared domain error — *must* be handled in the loader/action, or
|
|
147
|
+
* `makeLoader`/`makeAction` fails to type-check.
|
|
111
148
|
*/
|
|
112
|
-
declare function makeLoaderOrActionFactory<const Handlers>(config: {
|
|
149
|
+
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers>(config: {
|
|
113
150
|
errorHandlers: Handlers;
|
|
114
|
-
}, ..._validate: Handlers extends
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
151
|
+
}, ..._validate: HandlersAreValid<Handlers, DomainError> extends true ? [] : [eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>]) => {
|
|
152
|
+
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
153
|
+
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
118
154
|
};
|
|
119
155
|
//#endregion
|
|
120
156
|
export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
package/dist/index.mjs
CHANGED
|
@@ -51,48 +51,54 @@ const processRouteError = (e) => {
|
|
|
51
51
|
/**
|
|
52
52
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
59
|
-
* throw). Recovered values become loader/action data, typed precisely (non-generic).
|
|
54
|
+
* Declare the app's **domain errors** as the type argument, then register an
|
|
55
|
+
* *optional* handler per domain error in `errorHandlers` (**annotate each handler's
|
|
56
|
+
* param**). It's curried so you can pin the domain errors while the handler types
|
|
57
|
+
* are still inferred:
|
|
60
58
|
*
|
|
61
|
-
* Handling is optional: an *unregistered* error is allowed. If it implements
|
|
62
|
-
* `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
|
|
63
|
-
* error boundary as a 500.
|
|
64
|
-
*
|
|
65
|
-
* @example
|
|
66
59
|
* ```ts
|
|
67
|
-
*
|
|
60
|
+
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
61
|
+
*
|
|
62
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
68
63
|
* errorHandlers: {
|
|
69
64
|
* // throw → error boundary
|
|
70
65
|
* MyDomainError: (error: MyDomainError) =>
|
|
71
66
|
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
72
|
-
* //
|
|
73
|
-
* FormError: (error: FormError) => Respond.early({ reply: error.reply }),
|
|
67
|
+
* // DbError has no handler → falls through to the 500 default.
|
|
74
68
|
* },
|
|
75
69
|
* });
|
|
76
70
|
* ```
|
|
71
|
+
*
|
|
72
|
+
* A handler *remaps* the error by returning either a library route error
|
|
73
|
+
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
74
|
+
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
75
|
+
* throw). Recovered values become loader/action data, typed precisely.
|
|
76
|
+
*
|
|
77
|
+
* **Declared domain errors** may be left unhandled: if one implements
|
|
78
|
+
* `HttpServerRespondable` it's rendered automatically, otherwise it falls through
|
|
79
|
+
* to a 500. **Any other error** — a service-specific error the route consumes that
|
|
80
|
+
* isn't a declared domain error — *must* be handled in the loader/action, or
|
|
81
|
+
* `makeLoader`/`makeAction` fails to type-check.
|
|
77
82
|
*/
|
|
78
|
-
function makeLoaderOrActionFactory(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
83
|
+
function makeLoaderOrActionFactory() {
|
|
84
|
+
return function defineErrorHandlers(config, ..._validate) {
|
|
85
|
+
const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in config.errorHandlers;
|
|
86
|
+
const userHandlers = config.errorHandlers;
|
|
87
|
+
function makeLoaderOrAction(fn, ..._handle) {
|
|
88
|
+
return (args) => Effect.runPromise(fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
|
|
89
|
+
if (isUserError(e)) {
|
|
90
|
+
const out = userHandlers[e._tag](e);
|
|
91
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
92
|
+
}
|
|
93
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
94
|
+
if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
|
|
95
|
+
return internalServerError();
|
|
96
|
+
})));
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
makeLoader: makeLoaderOrAction,
|
|
100
|
+
makeAction: makeLoaderOrAction
|
|
101
|
+
};
|
|
96
102
|
};
|
|
97
103
|
}
|
|
98
104
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://schemastore.org/package.json",
|
|
3
3
|
"name": "react-router-effect",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"action",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"packageManager": {
|
|
63
63
|
"name": "pnpm",
|
|
64
64
|
"version": "11.9.0",
|
|
65
|
-
"onFail": "
|
|
65
|
+
"onFail": "warn"
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
68
|
"scripts": {
|
package/src/factory.ts
CHANGED
|
@@ -52,11 +52,6 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
|
|
|
52
52
|
? never
|
|
53
53
|
: DataWithResponseInit<ReturnableBodyOf<E>>;
|
|
54
54
|
|
|
55
|
-
/** The domain errors a handler map registers — derived from the handlers' params. */
|
|
56
|
-
type RegisteredError<Handlers> = {
|
|
57
|
-
[Tag in keyof Handlers]: Handlers[Tag] extends (error: infer Err) => unknown ? Err : never;
|
|
58
|
-
}[keyof Handlers];
|
|
59
|
-
|
|
60
55
|
/**
|
|
61
56
|
* What the registered handlers recover into loader/action data — derived from the
|
|
62
57
|
* handlers' returns, but only for handlers whose error can actually occur in `E`:
|
|
@@ -74,11 +69,47 @@ type RecoverOf<Handlers, E> = {
|
|
|
74
69
|
: never;
|
|
75
70
|
}[keyof Handlers];
|
|
76
71
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
(
|
|
81
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Errors the library deals with on the loader's behalf — so the loader needn't
|
|
74
|
+
* handle them itself:
|
|
75
|
+
* - the app's **declared domain errors** (`DomainError`) — handled by a registered
|
|
76
|
+
* handler, or left to the 500 / auto-respond default;
|
|
77
|
+
* - **library route errors** raised directly via `Respond`;
|
|
78
|
+
* - anything that renders itself via **`HttpServerRespondable`**.
|
|
79
|
+
*/
|
|
80
|
+
type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What remains in a loader/action's error channel that the library will NOT handle
|
|
84
|
+
* — i.e. service-specific errors the route consumes that aren't declared domain
|
|
85
|
+
* errors. The loader/action must handle these itself (catch or map them).
|
|
86
|
+
*/
|
|
87
|
+
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The shape a handler map must satisfy: an OPTIONAL handler per declared domain
|
|
91
|
+
* error, keyed by its tag, taking that error and returning a library route error
|
|
92
|
+
* or an `Effect` failing with a response. Used only as a validation constraint —
|
|
93
|
+
* the concrete handler types stay precise via the `const Handlers` inference.
|
|
94
|
+
*/
|
|
95
|
+
type ValidHandlers<DomainError extends Tagged> = {
|
|
96
|
+
[Tag in DomainError["_tag"]]?: (
|
|
97
|
+
error: Extract<DomainError, { readonly _tag: Tag }>,
|
|
98
|
+
) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* True when every registered handler is keyed by a declared domain error's tag and
|
|
103
|
+
* returns a library route error or a failing `Effect`. A handler for an unknown tag
|
|
104
|
+
* (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
|
|
105
|
+
*/
|
|
106
|
+
type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [
|
|
107
|
+
DomainError["_tag"],
|
|
108
|
+
]
|
|
109
|
+
? Handlers extends ValidHandlers<DomainError>
|
|
110
|
+
? true
|
|
111
|
+
: false
|
|
112
|
+
: false;
|
|
82
113
|
|
|
83
114
|
// ---------------------------------------------------------------------------
|
|
84
115
|
// Internal runtime helpers.
|
|
@@ -103,95 +134,113 @@ const processRouteError = (
|
|
|
103
134
|
/**
|
|
104
135
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
105
136
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
111
|
-
* throw). Recovered values become loader/action data, typed precisely (non-generic).
|
|
112
|
-
*
|
|
113
|
-
* Handling is optional: an *unregistered* error is allowed. If it implements
|
|
114
|
-
* `HttpServerRespondable` it's rendered automatically; otherwise it rejects to the
|
|
115
|
-
* error boundary as a 500.
|
|
137
|
+
* Declare the app's **domain errors** as the type argument, then register an
|
|
138
|
+
* *optional* handler per domain error in `errorHandlers` (**annotate each handler's
|
|
139
|
+
* param**). It's curried so you can pin the domain errors while the handler types
|
|
140
|
+
* are still inferred:
|
|
116
141
|
*
|
|
117
|
-
* @example
|
|
118
142
|
* ```ts
|
|
119
|
-
*
|
|
143
|
+
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
144
|
+
*
|
|
145
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
120
146
|
* errorHandlers: {
|
|
121
147
|
* // throw → error boundary
|
|
122
148
|
* MyDomainError: (error: MyDomainError) =>
|
|
123
149
|
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
124
|
-
* //
|
|
125
|
-
* FormError: (error: FormError) => Respond.early({ reply: error.reply }),
|
|
150
|
+
* // DbError has no handler → falls through to the 500 default.
|
|
126
151
|
* },
|
|
127
152
|
* });
|
|
128
153
|
* ```
|
|
154
|
+
*
|
|
155
|
+
* A handler *remaps* the error by returning either a library route error
|
|
156
|
+
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
157
|
+
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
158
|
+
* throw). Recovered values become loader/action data, typed precisely.
|
|
159
|
+
*
|
|
160
|
+
* **Declared domain errors** may be left unhandled: if one implements
|
|
161
|
+
* `HttpServerRespondable` it's rendered automatically, otherwise it falls through
|
|
162
|
+
* to a 500. **Any other error** — a service-specific error the route consumes that
|
|
163
|
+
* isn't a declared domain error — *must* be handled in the loader/action, or
|
|
164
|
+
* `makeLoader`/`makeAction` fails to type-check.
|
|
129
165
|
*/
|
|
130
|
-
export function makeLoaderOrActionFactory<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
166
|
+
export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
|
|
167
|
+
return function defineErrorHandlers<const Handlers>(
|
|
168
|
+
config: { errorHandlers: Handlers },
|
|
169
|
+
// Validation: a handler for a non-domain error, or one that doesn't return a
|
|
170
|
+
// route error / failing `Effect`, makes this rest parameter required and forces
|
|
171
|
+
// a compile error at the call.
|
|
172
|
+
..._validate: HandlersAreValid<Handlers, DomainError> extends true
|
|
173
|
+
? []
|
|
174
|
+
: [
|
|
175
|
+
eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>,
|
|
176
|
+
]
|
|
177
|
+
) {
|
|
178
|
+
const isUserError = (e: unknown): e is DomainError =>
|
|
179
|
+
typeof e === "object" &&
|
|
180
|
+
e !== null &&
|
|
181
|
+
"_tag" in e &&
|
|
182
|
+
(e as Tagged)._tag in (config.errorHandlers as object);
|
|
183
|
+
|
|
184
|
+
// Uniform call signature for dispatch (the per-tag handler types are narrower).
|
|
185
|
+
const userHandlers = config.errorHandlers as unknown as Record<
|
|
186
|
+
string,
|
|
187
|
+
ErrorHandler<DomainError>
|
|
188
|
+
>;
|
|
189
|
+
|
|
190
|
+
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
|
|
191
|
+
fn: (args: Args) => Effect.Effect<A, E>,
|
|
192
|
+
// If the effect can still fail with something the library won't handle — a
|
|
193
|
+
// service-specific error that isn't a declared domain error, a library route
|
|
194
|
+
// error, or respondable — this rest parameter becomes required and the call
|
|
195
|
+
// fails to type-check, forcing the loader/action to handle it.
|
|
196
|
+
..._handle: [Unhandled<E, DomainError>] extends [never]
|
|
197
|
+
? []
|
|
198
|
+
: [
|
|
199
|
+
theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Unhandled<
|
|
200
|
+
E,
|
|
201
|
+
DomainError
|
|
202
|
+
>,
|
|
203
|
+
]
|
|
204
|
+
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
|
|
205
|
+
return (args: Args) =>
|
|
206
|
+
// The internal channel is deliberately loose (`unknown` success); the outer
|
|
207
|
+
// cast restores the precise resolved type (computed from `E` and the handler
|
|
208
|
+
// map). Sound at runtime — the values produced are exactly those types.
|
|
209
|
+
Effect.runPromise(
|
|
210
|
+
fn(args).pipe(
|
|
211
|
+
// Catch the whole error channel and dispatch. The refinement is `e is E`
|
|
212
|
+
// (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
|
|
213
|
+
// overload crashes tsc over a generic `E`). A declared domain error with
|
|
214
|
+
// no handler (and not respondable) falls through to the 500 default.
|
|
215
|
+
Effect.catchIf(
|
|
216
|
+
(_e): _e is E => true,
|
|
217
|
+
(e): Effect.Effect<unknown, FailureResponse> => {
|
|
218
|
+
// Registered domain error → remap. A library-error return is processed
|
|
219
|
+
// by the internal dispatch; an `Effect` return is used as-is.
|
|
220
|
+
if (isUserError(e)) {
|
|
221
|
+
const out = userHandlers[e._tag](e);
|
|
222
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
223
|
+
}
|
|
224
|
+
// A library route error raised directly in the loader → recover/throw.
|
|
225
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
226
|
+
// Respondable → render its own response and throw it.
|
|
227
|
+
if (HttpServerRespondable.isRespondable(e)) {
|
|
228
|
+
return HttpServerRespondable.toResponse(e).pipe(
|
|
229
|
+
Effect.flatMap((res) =>
|
|
230
|
+
Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return internalServerError();
|
|
235
|
+
},
|
|
236
|
+
),
|
|
187
237
|
),
|
|
188
|
-
),
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
makeAction: makeLoaderOrAction,
|
|
238
|
+
) as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
makeLoader: makeLoaderOrAction,
|
|
243
|
+
makeAction: makeLoaderOrAction,
|
|
244
|
+
};
|
|
196
245
|
};
|
|
197
246
|
}
|