react-router-effect 0.1.0 → 0.3.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 +61 -11
- package/dist/index.d.mts +90 -23
- package/dist/index.mjs +61 -32
- package/package.json +4 -4
- package/src/factory.ts +156 -67
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
|
|
|
@@ -129,10 +140,49 @@ class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
|
|
|
129
140
|
|
|
130
141
|
A registered handler, if present, still takes precedence over the error's own response.
|
|
131
142
|
|
|
143
|
+
### Providing services with a runtime
|
|
144
|
+
|
|
145
|
+
Pass a `runtime` (an effect [`ManagedRuntime`](https://effect.website)) and your loaders/actions
|
|
146
|
+
may require its services directly — no per-call `Effect.provide`. The runtime is built once and
|
|
147
|
+
reused for every request:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// app/runtime.server.ts
|
|
151
|
+
import { ManagedRuntime } from "effect";
|
|
152
|
+
export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
|
|
153
|
+
|
|
154
|
+
// app/route.server.ts
|
|
155
|
+
export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
156
|
+
runtime: appRuntime,
|
|
157
|
+
errorHandlers: { ... },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
|
|
161
|
+
const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
const svc = yield* MyService;
|
|
164
|
+
return { profile: yield* svc.load(args.params.id) };
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The runtime's services become the effect's allowed requirement channel: requiring a service the
|
|
170
|
+
runtime provides type-checks, while requiring one it _doesn't_ is a compile error. With no
|
|
171
|
+
`runtime`, effects must require nothing.
|
|
172
|
+
|
|
173
|
+
`errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
makeLoaderOrActionFactory()({ runtime });
|
|
177
|
+
makeLoaderOrActionFactory()({});
|
|
178
|
+
```
|
|
179
|
+
|
|
132
180
|
## API
|
|
133
181
|
|
|
134
|
-
- **`makeLoaderOrActionFactory({ errorHandlers })`** →
|
|
135
|
-
(
|
|
182
|
+
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime? })`** →
|
|
183
|
+
`{ makeLoader, makeAction }` (both are the same wrapper). Both config fields are optional. A
|
|
184
|
+
non-domain error left in a loader/action's error channel — or a required service the `runtime`
|
|
185
|
+
doesn't provide — is a compile error.
|
|
136
186
|
- **`Respond`** — `early` (recover), `throw`, `redirect`.
|
|
137
187
|
- **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
|
|
138
188
|
route errors, and **`isRouteError`** to narrow them.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Effect, ManagedRuntime } 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,104 @@ 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).
|
|
94
|
-
*
|
|
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.
|
|
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:
|
|
98
124
|
*
|
|
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.
|
|
148
|
+
*
|
|
149
|
+
* Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
|
|
150
|
+
* to provide your app's services once. Loader/action effects may then require those
|
|
151
|
+
* services directly — no per-call `Effect.provide`:
|
|
152
|
+
*
|
|
153
|
+
* ```ts
|
|
154
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
155
|
+
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
156
|
+
* errorHandlers: { ... },
|
|
157
|
+
* });
|
|
158
|
+
*
|
|
159
|
+
* // `MyService` is satisfied by the runtime, not provided here:
|
|
160
|
+
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
161
|
+
* Effect.gen(function* () {
|
|
162
|
+
* const svc = yield* MyService;
|
|
163
|
+
* return { data: yield* svc.load(args) };
|
|
164
|
+
* }),
|
|
165
|
+
* );
|
|
166
|
+
* ```
|
|
111
167
|
*/
|
|
112
|
-
declare function makeLoaderOrActionFactory<const Handlers>(config: {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
168
|
+
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers = {}, RServices = never>(config: {
|
|
169
|
+
/**
|
|
170
|
+
* An optional handler per declared domain error. Omit entirely to register
|
|
171
|
+
* none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
|
|
172
|
+
* when there are no domain errors at all).
|
|
173
|
+
*/
|
|
174
|
+
errorHandlers?: Handlers;
|
|
175
|
+
/**
|
|
176
|
+
* The app runtime that provides services to loader/action effects. When
|
|
177
|
+
* set, effects may require its services (`RServices`, inferred from here)
|
|
178
|
+
* without providing layers, and runs go through `runtime.runPromise`. When
|
|
179
|
+
* omitted, `RServices` is `never` and effects must require nothing.
|
|
180
|
+
*/
|
|
181
|
+
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
182
|
+
}, ..._validate: HandlersAreValid<Handlers, DomainError> extends true ? [] : [eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>]) => {
|
|
183
|
+
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
184
|
+
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
118
185
|
};
|
|
119
186
|
//#endregion
|
|
120
187
|
export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
package/dist/index.mjs
CHANGED
|
@@ -51,48 +51,77 @@ 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.
|
|
82
|
+
*
|
|
83
|
+
* Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
|
|
84
|
+
* to provide your app's services once. Loader/action effects may then require those
|
|
85
|
+
* services directly — no per-call `Effect.provide`:
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
89
|
+
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
90
|
+
* errorHandlers: { ... },
|
|
91
|
+
* });
|
|
92
|
+
*
|
|
93
|
+
* // `MyService` is satisfied by the runtime, not provided here:
|
|
94
|
+
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
95
|
+
* Effect.gen(function* () {
|
|
96
|
+
* const svc = yield* MyService;
|
|
97
|
+
* return { data: yield* svc.load(args) };
|
|
98
|
+
* }),
|
|
99
|
+
* );
|
|
100
|
+
* ```
|
|
77
101
|
*/
|
|
78
|
-
function makeLoaderOrActionFactory(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
function makeLoaderOrActionFactory() {
|
|
103
|
+
return function defineErrorHandlers(config, ..._validate) {
|
|
104
|
+
const runtime = config.runtime;
|
|
105
|
+
const userHandlers = config.errorHandlers ?? {};
|
|
106
|
+
const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
|
|
107
|
+
function makeLoaderOrAction(fn, ..._handle) {
|
|
108
|
+
return (args) => {
|
|
109
|
+
const program = fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
|
|
110
|
+
if (isUserError(e)) {
|
|
111
|
+
const out = userHandlers[e._tag](e);
|
|
112
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
113
|
+
}
|
|
114
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
115
|
+
if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
|
|
116
|
+
return internalServerError();
|
|
117
|
+
}));
|
|
118
|
+
return runtime ? runtime.runPromise(program) : Effect.runPromise(program);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
makeLoader: makeLoaderOrAction,
|
|
123
|
+
makeAction: makeLoaderOrAction
|
|
124
|
+
};
|
|
96
125
|
};
|
|
97
126
|
}
|
|
98
127
|
//#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.3.0",
|
|
5
5
|
"description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"action",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"playwright": "1.60.0",
|
|
49
49
|
"react": "^19.2.7",
|
|
50
50
|
"react-dom": "^19.2.7",
|
|
51
|
-
"react-router": "^
|
|
51
|
+
"react-router": "^8.0.0",
|
|
52
52
|
"typescript": "^6.0.3",
|
|
53
53
|
"vite-plus": "latest",
|
|
54
54
|
"vitest": "4.1.9",
|
|
@@ -56,13 +56,13 @@
|
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"effect": "^4.0.0-beta.88",
|
|
59
|
-
"react-router": "^7.16.0"
|
|
59
|
+
"react-router": "^7.16.0 || ^8.0.0"
|
|
60
60
|
},
|
|
61
61
|
"devEngines": {
|
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Effect, type ManagedRuntime } from "effect";
|
|
2
2
|
import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
|
|
3
3
|
import {
|
|
4
4
|
data,
|
|
@@ -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,66 +134,118 @@ 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.
|
|
165
|
+
*
|
|
166
|
+
* Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
|
|
167
|
+
* to provide your app's services once. Loader/action effects may then require those
|
|
168
|
+
* services directly — no per-call `Effect.provide`:
|
|
169
|
+
*
|
|
170
|
+
* ```ts
|
|
171
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
172
|
+
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
173
|
+
* errorHandlers: { ... },
|
|
174
|
+
* });
|
|
175
|
+
*
|
|
176
|
+
* // `MyService` is satisfied by the runtime, not provided here:
|
|
177
|
+
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
178
|
+
* Effect.gen(function* () {
|
|
179
|
+
* const svc = yield* MyService;
|
|
180
|
+
* return { data: yield* svc.load(args) };
|
|
181
|
+
* }),
|
|
182
|
+
* );
|
|
183
|
+
* ```
|
|
129
184
|
*/
|
|
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
|
-
|
|
185
|
+
export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
|
|
186
|
+
return function defineErrorHandlers<const Handlers = {}, RServices = never>(
|
|
187
|
+
config: {
|
|
188
|
+
/**
|
|
189
|
+
* An optional handler per declared domain error. Omit entirely to register
|
|
190
|
+
* none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
|
|
191
|
+
* when there are no domain errors at all).
|
|
192
|
+
*/
|
|
193
|
+
errorHandlers?: Handlers;
|
|
194
|
+
/**
|
|
195
|
+
* The app runtime that provides services to loader/action effects. When
|
|
196
|
+
* set, effects may require its services (`RServices`, inferred from here)
|
|
197
|
+
* without providing layers, and runs go through `runtime.runPromise`. When
|
|
198
|
+
* omitted, `RServices` is `never` and effects must require nothing.
|
|
199
|
+
*/
|
|
200
|
+
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
201
|
+
},
|
|
202
|
+
// Validation: a handler for a non-domain error, or one that doesn't return a
|
|
203
|
+
// route error / failing `Effect`, makes this rest parameter required and forces
|
|
204
|
+
// a compile error at the call.
|
|
205
|
+
..._validate: HandlersAreValid<Handlers, DomainError> extends true
|
|
206
|
+
? []
|
|
207
|
+
: [
|
|
208
|
+
eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>,
|
|
209
|
+
]
|
|
210
|
+
) {
|
|
211
|
+
const runtime = config.runtime;
|
|
212
|
+
|
|
213
|
+
// Uniform call signature for dispatch (the per-tag handler types are narrower).
|
|
214
|
+
// Defaults to an empty map when `errorHandlers` is omitted.
|
|
215
|
+
const userHandlers = (config.errorHandlers ?? {}) as unknown as Record<
|
|
216
|
+
string,
|
|
217
|
+
ErrorHandler<DomainError>
|
|
218
|
+
>;
|
|
219
|
+
|
|
220
|
+
const isUserError = (e: unknown): e is DomainError =>
|
|
221
|
+
typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
|
|
222
|
+
|
|
223
|
+
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
|
|
224
|
+
// The effect may require `RServices` — the services the configured `runtime`
|
|
225
|
+
// provides (or `never` when no runtime is configured, requiring nothing).
|
|
226
|
+
fn: (args: Args) => Effect.Effect<A, E, RServices>,
|
|
227
|
+
// If the effect can still fail with something the library won't handle — a
|
|
228
|
+
// service-specific error that isn't a declared domain error, a library route
|
|
229
|
+
// error, or respondable — this rest parameter becomes required and the call
|
|
230
|
+
// fails to type-check, forcing the loader/action to handle it.
|
|
231
|
+
..._handle: [Unhandled<E, DomainError>] extends [never]
|
|
232
|
+
? []
|
|
233
|
+
: [
|
|
234
|
+
theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Unhandled<
|
|
235
|
+
E,
|
|
236
|
+
DomainError
|
|
237
|
+
>,
|
|
238
|
+
]
|
|
239
|
+
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
|
|
240
|
+
return (args: Args) => {
|
|
241
|
+
// The internal channel is deliberately loose (`unknown` success); the outer
|
|
242
|
+
// cast restores the precise resolved type (computed from `E` and the handler
|
|
243
|
+
// map). Sound at runtime — the values produced are exactly those types.
|
|
244
|
+
const program = fn(args).pipe(
|
|
162
245
|
// Catch the whole error channel and dispatch. The refinement is `e is E`
|
|
163
246
|
// (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
|
|
164
|
-
// overload crashes tsc over a generic `E`).
|
|
165
|
-
//
|
|
247
|
+
// overload crashes tsc over a generic `E`). A declared domain error with
|
|
248
|
+
// no handler (and not respondable) falls through to the 500 default.
|
|
166
249
|
Effect.catchIf(
|
|
167
250
|
(_e): _e is E => true,
|
|
168
251
|
(e): Effect.Effect<unknown, FailureResponse> => {
|
|
@@ -185,13 +268,19 @@ export function makeLoaderOrActionFactory<const Handlers>(
|
|
|
185
268
|
return internalServerError();
|
|
186
269
|
},
|
|
187
270
|
),
|
|
188
|
-
)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
271
|
+
);
|
|
272
|
+
// Run against the configured runtime so its services satisfy the effect's
|
|
273
|
+
// `R`; with no runtime, the effect requires nothing and runs standalone.
|
|
274
|
+
const result = runtime
|
|
275
|
+
? runtime.runPromise(program)
|
|
276
|
+
: Effect.runPromise(program as Effect.Effect<unknown, FailureResponse>);
|
|
277
|
+
return result as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
makeLoader: makeLoaderOrAction,
|
|
283
|
+
makeAction: makeLoaderOrAction,
|
|
284
|
+
};
|
|
196
285
|
};
|
|
197
286
|
}
|