react-router-effect 0.1.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 +152 -0
- package/dist/index.d.mts +120 -0
- package/dist/index.mjs +99 -0
- package/package.json +80 -0
- package/src/errors.ts +59 -0
- package/src/factory.ts +197 -0
- package/src/index.ts +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# react-router-effect
|
|
2
|
+
|
|
3
|
+
Wrap [React Router](https://reactrouter.com) framework-mode loaders and actions with
|
|
4
|
+
[Effect](https://effect.website), and get **typed, declarative error handling** for free.
|
|
5
|
+
|
|
6
|
+
Write your loader/action as an `Effect`. When it short-circuits with a tagged error, the
|
|
7
|
+
library decides — based on handlers you register once — whether to **recover** (return data
|
|
8
|
+
the component reads via `useLoaderData`) or **throw** (send it to the error boundary or issue
|
|
9
|
+
a redirect). The resolved type of every loader/action reflects exactly what it can return.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const user = yield* getUser(args); // may fail with your domain errors
|
|
15
|
+
if (!user.onboarded) {
|
|
16
|
+
yield* Respond.redirect("/onboarding"); // throw → redirect
|
|
17
|
+
}
|
|
18
|
+
return { user };
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
// loader: (args) => Promise<{ user: User } /* | recovered shapes */>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
vp add react-router-effect effect react-router
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`effect` and `react-router` are peer dependencies.
|
|
31
|
+
|
|
32
|
+
## Concepts
|
|
33
|
+
|
|
34
|
+
A loader/action effect can short-circuit in three ways, via the `Respond` helpers:
|
|
35
|
+
|
|
36
|
+
| Helper | Outcome | Where it lands |
|
|
37
|
+
| ------------------------------ | ----------- | --------------------------------------------------- |
|
|
38
|
+
| `Respond.early(value, init?)` | **recover** | resolves with `data(value, init)` → `useLoaderData` |
|
|
39
|
+
| `Respond.throw(value, init?)` | **throw** | rejects with `data(value, init)` → error boundary |
|
|
40
|
+
| `Respond.redirect(url, init?)` | **throw** | rejects with a redirect `Response` |
|
|
41
|
+
|
|
42
|
+
Your own **domain errors** are mapped to one of those outcomes by handlers you register with
|
|
43
|
+
the factory. A handler _remaps_ an error by returning either:
|
|
44
|
+
|
|
45
|
+
- a library route error — `Respond.early(...)` / `Respond.throw(...)` / `Respond.redirect(...)`; or
|
|
46
|
+
- an `Effect` — `Effect.succeed(value)` to **recover** with `value`, or `Effect.fail(response)`
|
|
47
|
+
to **throw** a `Response` / `DataWithResponseInit`.
|
|
48
|
+
|
|
49
|
+
Handling is **optional**:
|
|
50
|
+
|
|
51
|
+
- An **unregistered** error that implements [`HttpServerRespondable`](https://effect.website)
|
|
52
|
+
is rendered automatically from its own response.
|
|
53
|
+
- Any other unregistered error falls through to a **500**.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### 1. Configure the factory once
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// app/route.server.ts
|
|
61
|
+
import { Data, Effect } from "effect";
|
|
62
|
+
import { makeLoaderOrActionFactory, Respond as baseRespond } from "react-router-effect";
|
|
63
|
+
|
|
64
|
+
class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
|
|
65
|
+
class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
|
|
66
|
+
|
|
67
|
+
export const { makeLoader, makeAction } = makeLoaderOrActionFactory({
|
|
68
|
+
errorHandlers: {
|
|
69
|
+
// recover: short-circuit and hand the reply to the component
|
|
70
|
+
FormError: (error: FormError) => baseRespond.early({ reply: error.reply }),
|
|
71
|
+
// throw: send to the error boundary
|
|
72
|
+
BadInputError: (error: BadInputError) =>
|
|
73
|
+
Effect.fail(new Response(error.message, { status: 400 })),
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Extend Respond with app-specific helpers if you like:
|
|
78
|
+
export const Respond = {
|
|
79
|
+
...baseRespond,
|
|
80
|
+
formError: (reply: SubmissionResponse) => new FormError({ reply }),
|
|
81
|
+
};
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
> **Annotate each handler's parameter.** The registered error set and the precise recover
|
|
85
|
+
> types are derived from the handler map's parameter and return types.
|
|
86
|
+
|
|
87
|
+
### 2. Write loaders/actions as effects
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// app/routes/profile.ts
|
|
91
|
+
import { Effect } from "effect";
|
|
92
|
+
import { makeLoader, Respond } from "../route.server.ts";
|
|
93
|
+
import type { Route } from "./+types/profile";
|
|
94
|
+
|
|
95
|
+
const loaderEffect = ({ params }: Route.LoaderArgs) =>
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const profile = yield* getProfile(params.id); // may fail with FormError / BadInputError
|
|
98
|
+
if (!profile.public) {
|
|
99
|
+
yield* Respond.early({ restricted: true }); // recover with typed data
|
|
100
|
+
}
|
|
101
|
+
return { profile };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const loader = makeLoader((args: Route.LoaderArgs) => loaderEffect(args));
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The resolved type is computed from the effect and your handlers:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
type LoaderData = Route.ComponentProps["loaderData"];
|
|
111
|
+
// { profile: Profile } | DataWithResponseInit<{ restricted: boolean }>
|
|
112
|
+
// ( BadInputError throws, so it never appears here )
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Self-rendering domain errors
|
|
116
|
+
|
|
117
|
+
If a domain error implements `HttpServerRespondable`, you don't need to register a handler —
|
|
118
|
+
it renders its own response:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
|
|
122
|
+
|
|
123
|
+
class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
|
|
124
|
+
[HttpServerRespondable.symbol]() {
|
|
125
|
+
return HttpServerResponse.json({ error: "Not authorized" }, { status: 403 }).pipe(Effect.orDie);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A registered handler, if present, still takes precedence over the error's own response.
|
|
131
|
+
|
|
132
|
+
## API
|
|
133
|
+
|
|
134
|
+
- **`makeLoaderOrActionFactory({ errorHandlers })`** → `{ makeLoader, makeAction, makeLoaderOrAction }`
|
|
135
|
+
(the three are the same wrapper under different names).
|
|
136
|
+
- **`Respond`** — `early` (recover), `throw`, `redirect`.
|
|
137
|
+
- **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
|
|
138
|
+
route errors, and **`isRouteError`** to narrow them.
|
|
139
|
+
- **`ErrorHandler<Err>`** — the handler signature type.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
vp install # install dependencies
|
|
145
|
+
vp test # run the test suite
|
|
146
|
+
vp check # format, lint, type-check
|
|
147
|
+
vp pack # build the library
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { ActionFunctionArgs, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } from "react-router";
|
|
3
|
+
|
|
4
|
+
//#region src/errors.d.ts
|
|
5
|
+
declare const ReturnableDataError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
|
|
6
|
+
readonly _tag: "ReturnableRouteError";
|
|
7
|
+
} & Readonly<A>;
|
|
8
|
+
/** Recoverable: short-circuits the loader but returns `data(...)` the component reads. */
|
|
9
|
+
declare class ReturnableDataError<D> extends ReturnableDataError_base<{
|
|
10
|
+
data: D;
|
|
11
|
+
init?: number | ResponseInit;
|
|
12
|
+
}> {}
|
|
13
|
+
declare const ThrowableDataError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
|
|
14
|
+
readonly _tag: "ThrowableRouteError";
|
|
15
|
+
} & Readonly<A>;
|
|
16
|
+
/** Throwable: rejects with `data(...)` → boundary as an `ErrorResponse`. */
|
|
17
|
+
declare class ThrowableDataError<D> extends ThrowableDataError_base<{
|
|
18
|
+
data: D;
|
|
19
|
+
init?: number | ResponseInit;
|
|
20
|
+
}> {}
|
|
21
|
+
declare const ThrowableRedirectError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
|
|
22
|
+
readonly _tag: "ThrowableRedirectError";
|
|
23
|
+
} & Readonly<A>;
|
|
24
|
+
/** Throwable: rejects with a redirect `Response`. */
|
|
25
|
+
declare class ThrowableRedirectError extends ThrowableRedirectError_base<{
|
|
26
|
+
url: string;
|
|
27
|
+
init?: number | ResponseInit;
|
|
28
|
+
}> {}
|
|
29
|
+
/** Every library route error, as a single union. */
|
|
30
|
+
type AnyRouteError = ReturnableDataError<unknown> | ThrowableDataError<unknown> | ThrowableRedirectError;
|
|
31
|
+
/**
|
|
32
|
+
* Standardized helpers for raising the library's route errors. Consumers extend it
|
|
33
|
+
* by spreading into their own object — opinionated helpers (e.g. a form-library
|
|
34
|
+
* `formError`) live in the consumer, not here:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* export const Respond = {
|
|
38
|
+
* ...baseRespond,
|
|
39
|
+
* formError: (reply) => baseRespond.early({ reply }, 400),
|
|
40
|
+
* };
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare const Respond: {
|
|
44
|
+
/** Recover: short-circuit and hand `value` to the component as loader/action data. */early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>; /** Throw: short-circuit to the error boundary with `data(value, init)`. */
|
|
45
|
+
throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>; /** Throw: short-circuit with a redirect `Response`. */
|
|
46
|
+
redirect: (url: string, init?: number | ResponseInit) => ThrowableRedirectError;
|
|
47
|
+
};
|
|
48
|
+
/** Narrows an unknown value to one of the library's route errors. */
|
|
49
|
+
declare const isRouteError: (value: unknown) => value is AnyRouteError;
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/factory.d.ts
|
|
52
|
+
/**
|
|
53
|
+
* Everything a failure branch rejects with. React Router routes a thrown
|
|
54
|
+
* `DataWithResponseInit` to the boundary as an `ErrorResponse`, and a thrown
|
|
55
|
+
* `Response` (including `redirect(...)`) as-is. Pinning every `Effect.fail` branch
|
|
56
|
+
* to this union keeps inference from fixing the error channel to the first one.
|
|
57
|
+
*/
|
|
58
|
+
type FailureResponse = UNSAFE_DataWithResponseInit<unknown> | Response;
|
|
59
|
+
/**
|
|
60
|
+
* What a handler may return, to *remap* a domain error:
|
|
61
|
+
* - a **library route error** — `Respond.early(value)` (recover), `Respond.throw(data)`
|
|
62
|
+
* or `Respond.redirect(url)` (throw) — which the library then processes; or
|
|
63
|
+
* - an **`Effect`** — `Effect.succeed(value)` to recover with `value`, or
|
|
64
|
+
* `Effect.fail(response)` to throw `response`.
|
|
65
|
+
*/
|
|
66
|
+
type ErrorHandler<Err> = (error: Err) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
67
|
+
/** The body of every `ReturnableDataError` raised directly in `E` (`Respond.early`). */
|
|
68
|
+
type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : never;
|
|
69
|
+
/**
|
|
70
|
+
* The recover contribution of a `Respond.early` raised directly in the loader.
|
|
71
|
+
* Guarded so a loader that never recovers directly doesn't leak
|
|
72
|
+
* `DataWithResponseInit<never>` into its return type.
|
|
73
|
+
*/
|
|
74
|
+
type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_DataWithResponseInit<ReturnableBodyOf<E>>;
|
|
75
|
+
/**
|
|
76
|
+
* What the registered handlers recover into loader/action data — derived from the
|
|
77
|
+
* handlers' returns, but only for handlers whose error can actually occur in `E`:
|
|
78
|
+
* - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
|
|
79
|
+
* - an `Effect.succeed(value)` recovers as `value`;
|
|
80
|
+
* - throwables, redirects and `Effect.fail(...)` contribute nothing.
|
|
81
|
+
*/
|
|
82
|
+
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
|
+
/** Shape every handler must satisfy: return a library route error or an `Effect` failing with a response. */
|
|
84
|
+
type ValidHandlers = Record<string, (error: never) => AnyRouteError | Effect.Effect<unknown, FailureResponse>>;
|
|
85
|
+
/**
|
|
86
|
+
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
87
|
+
*
|
|
88
|
+
* Register a handler per domain error in `errorHandlers` (**annotate each handler's
|
|
89
|
+
* param** — the registered set and recover types are derived from the map). A
|
|
90
|
+
* handler *remaps* the error by returning either a library route error
|
|
91
|
+
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
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.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory({
|
|
102
|
+
* errorHandlers: {
|
|
103
|
+
* // throw → error boundary
|
|
104
|
+
* MyDomainError: (error: MyDomainError) =>
|
|
105
|
+
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
106
|
+
* // remap → recover via a library returnable
|
|
107
|
+
* FormError: (error: FormError) => Respond.early({ reply: error.reply }),
|
|
108
|
+
* },
|
|
109
|
+
* });
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
declare function makeLoaderOrActionFactory<const Handlers>(config: {
|
|
113
|
+
errorHandlers: Handlers;
|
|
114
|
+
}, ..._validate: Handlers extends ValidHandlers ? [] : [eachHandlerMustReturnARouteErrorOrAnEffectFailingWithAResponse: ValidHandlers]): {
|
|
115
|
+
makeLoaderOrAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
116
|
+
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
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>>;
|
|
118
|
+
};
|
|
119
|
+
//#endregion
|
|
120
|
+
export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Data, Effect } from "effect";
|
|
2
|
+
import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { data, redirect } from "react-router";
|
|
4
|
+
//#region src/errors.ts
|
|
5
|
+
/** Recoverable: short-circuits the loader but returns `data(...)` the component reads. */
|
|
6
|
+
var ReturnableDataError = class extends Data.TaggedError("ReturnableRouteError") {};
|
|
7
|
+
/** Throwable: rejects with `data(...)` → boundary as an `ErrorResponse`. */
|
|
8
|
+
var ThrowableDataError = class extends Data.TaggedError("ThrowableRouteError") {};
|
|
9
|
+
/** Throwable: rejects with a redirect `Response`. */
|
|
10
|
+
var ThrowableRedirectError = class extends Data.TaggedError("ThrowableRedirectError") {};
|
|
11
|
+
/**
|
|
12
|
+
* Standardized helpers for raising the library's route errors. Consumers extend it
|
|
13
|
+
* by spreading into their own object — opinionated helpers (e.g. a form-library
|
|
14
|
+
* `formError`) live in the consumer, not here:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* export const Respond = {
|
|
18
|
+
* ...baseRespond,
|
|
19
|
+
* formError: (reply) => baseRespond.early({ reply }, 400),
|
|
20
|
+
* };
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
const Respond = {
|
|
24
|
+
/** Recover: short-circuit and hand `value` to the component as loader/action data. */
|
|
25
|
+
early: (value, init) => new ReturnableDataError({
|
|
26
|
+
data: value,
|
|
27
|
+
init
|
|
28
|
+
}),
|
|
29
|
+
/** Throw: short-circuit to the error boundary with `data(value, init)`. */
|
|
30
|
+
throw: (value, init) => new ThrowableDataError({
|
|
31
|
+
data: value,
|
|
32
|
+
init
|
|
33
|
+
}),
|
|
34
|
+
/** Throw: short-circuit with a redirect `Response`. */
|
|
35
|
+
redirect: (url, init) => new ThrowableRedirectError({
|
|
36
|
+
url,
|
|
37
|
+
init
|
|
38
|
+
})
|
|
39
|
+
};
|
|
40
|
+
/** Narrows an unknown value to one of the library's route errors. */
|
|
41
|
+
const isRouteError = (value) => value instanceof ReturnableDataError || value instanceof ThrowableDataError || value instanceof ThrowableRedirectError;
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/factory.ts
|
|
44
|
+
const internalServerError = () => Effect.fail(new Response("Internal Server Error", { status: 500 }));
|
|
45
|
+
/** The internal dispatch for a library route error: recover, throw, or redirect. */
|
|
46
|
+
const processRouteError = (e) => {
|
|
47
|
+
if (e instanceof ReturnableDataError) return Effect.succeed(data(e.data, e.init));
|
|
48
|
+
if (e instanceof ThrowableDataError) return Effect.fail(data(e.data, e.init));
|
|
49
|
+
return Effect.fail(redirect(e.url, e.init));
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
53
|
+
*
|
|
54
|
+
* Register a handler per domain error in `errorHandlers` (**annotate each handler's
|
|
55
|
+
* param** — the registered set and recover types are derived from the map). A
|
|
56
|
+
* handler *remaps* the error by returning either a library route error
|
|
57
|
+
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
58
|
+
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
59
|
+
* throw). Recovered values become loader/action data, typed precisely (non-generic).
|
|
60
|
+
*
|
|
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
|
+
* ```ts
|
|
67
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory({
|
|
68
|
+
* errorHandlers: {
|
|
69
|
+
* // throw → error boundary
|
|
70
|
+
* MyDomainError: (error: MyDomainError) =>
|
|
71
|
+
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
72
|
+
* // remap → recover via a library returnable
|
|
73
|
+
* FormError: (error: FormError) => Respond.early({ reply: error.reply }),
|
|
74
|
+
* },
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
function makeLoaderOrActionFactory(config, ..._validate) {
|
|
79
|
+
const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in config.errorHandlers;
|
|
80
|
+
const userHandlers = config.errorHandlers;
|
|
81
|
+
function makeLoaderOrAction(fn) {
|
|
82
|
+
return (args) => Effect.runPromise(fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
|
|
83
|
+
if (isUserError(e)) {
|
|
84
|
+
const out = userHandlers[e._tag](e);
|
|
85
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
86
|
+
}
|
|
87
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
88
|
+
if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
|
|
89
|
+
return internalServerError();
|
|
90
|
+
})));
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
makeLoaderOrAction,
|
|
94
|
+
makeLoader: makeLoaderOrAction,
|
|
95
|
+
makeAction: makeLoaderOrAction
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
export { Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://schemastore.org/package.json",
|
|
3
|
+
"name": "react-router-effect",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"action",
|
|
8
|
+
"effect",
|
|
9
|
+
"error-handling",
|
|
10
|
+
"loader",
|
|
11
|
+
"react-router",
|
|
12
|
+
"typescript"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/justinwaite/react-router-effect#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/justinwaite/react-router-effect/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Justin Waite <justindwaite@gmail.com>",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/justinwaite/react-router-effect.git"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src",
|
|
27
|
+
"!src/**/*.test.ts"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./dist/index.mjs",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@changesets/changelog-github": "^0.7.0",
|
|
39
|
+
"@changesets/cli": "^2.31.0",
|
|
40
|
+
"@types/node": "^25.6.2",
|
|
41
|
+
"@types/react": "^19.2.17",
|
|
42
|
+
"@types/react-dom": "^19.2.3",
|
|
43
|
+
"@typescript/native-preview": "7.0.0-dev.20260509.2",
|
|
44
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
45
|
+
"@vitest/browser": "4.1.9",
|
|
46
|
+
"@vitest/browser-playwright": "4.1.9",
|
|
47
|
+
"effect": "4.0.0-beta.88",
|
|
48
|
+
"playwright": "1.60.0",
|
|
49
|
+
"react": "^19.2.7",
|
|
50
|
+
"react-dom": "^19.2.7",
|
|
51
|
+
"react-router": "^7.16.0",
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"vite-plus": "latest",
|
|
54
|
+
"vitest": "4.1.9",
|
|
55
|
+
"vitest-browser-react": "^2.2.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"effect": "^4.0.0-beta.88",
|
|
59
|
+
"react-router": "^7.16.0"
|
|
60
|
+
},
|
|
61
|
+
"devEngines": {
|
|
62
|
+
"packageManager": {
|
|
63
|
+
"name": "pnpm",
|
|
64
|
+
"version": "11.9.0",
|
|
65
|
+
"onFail": "download"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"build": "vp pack",
|
|
70
|
+
"dev": "vp pack --watch",
|
|
71
|
+
"test": "vp test",
|
|
72
|
+
"test:unit": "vp test run --project unit",
|
|
73
|
+
"test:browser": "vp test run --project browser",
|
|
74
|
+
"test:browser:install": "vp exec playwright install chromium",
|
|
75
|
+
"check": "vp check",
|
|
76
|
+
"changeset": "changeset",
|
|
77
|
+
"changeset:version": "changeset version",
|
|
78
|
+
"changeset:release": "vp run build && changeset publish"
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Standardized errors — the library translates these out of the box.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
/** Recoverable: short-circuits the loader but returns `data(...)` the component reads. */
|
|
8
|
+
export class ReturnableDataError<D> extends Data.TaggedError("ReturnableRouteError")<{
|
|
9
|
+
data: D;
|
|
10
|
+
init?: number | ResponseInit;
|
|
11
|
+
}> {}
|
|
12
|
+
|
|
13
|
+
/** Throwable: rejects with `data(...)` → boundary as an `ErrorResponse`. */
|
|
14
|
+
export class ThrowableDataError<D> extends Data.TaggedError("ThrowableRouteError")<{
|
|
15
|
+
data: D;
|
|
16
|
+
init?: number | ResponseInit;
|
|
17
|
+
}> {}
|
|
18
|
+
|
|
19
|
+
/** Throwable: rejects with a redirect `Response`. */
|
|
20
|
+
export class ThrowableRedirectError extends Data.TaggedError("ThrowableRedirectError")<{
|
|
21
|
+
url: string;
|
|
22
|
+
init?: number | ResponseInit;
|
|
23
|
+
}> {}
|
|
24
|
+
|
|
25
|
+
/** Every library route error, as a single union. */
|
|
26
|
+
export type AnyRouteError =
|
|
27
|
+
| ReturnableDataError<unknown>
|
|
28
|
+
| ThrowableDataError<unknown>
|
|
29
|
+
| ThrowableRedirectError;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Standardized helpers for raising the library's route errors. Consumers extend it
|
|
33
|
+
* by spreading into their own object — opinionated helpers (e.g. a form-library
|
|
34
|
+
* `formError`) live in the consumer, not here:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* export const Respond = {
|
|
38
|
+
* ...baseRespond,
|
|
39
|
+
* formError: (reply) => baseRespond.early({ reply }, 400),
|
|
40
|
+
* };
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export const Respond = {
|
|
44
|
+
/** Recover: short-circuit and hand `value` to the component as loader/action data. */
|
|
45
|
+
early: <D>(value: D, init?: number | ResponseInit) =>
|
|
46
|
+
new ReturnableDataError({ data: value, init }),
|
|
47
|
+
/** Throw: short-circuit to the error boundary with `data(value, init)`. */
|
|
48
|
+
throw: <D>(value: D, init?: number | ResponseInit) =>
|
|
49
|
+
new ThrowableDataError({ data: value, init }),
|
|
50
|
+
/** Throw: short-circuit with a redirect `Response`. */
|
|
51
|
+
redirect: (url: string, init?: number | ResponseInit) =>
|
|
52
|
+
new ThrowableRedirectError({ url, init }),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** Narrows an unknown value to one of the library's route errors. */
|
|
56
|
+
export const isRouteError = (value: unknown): value is AnyRouteError =>
|
|
57
|
+
value instanceof ReturnableDataError ||
|
|
58
|
+
value instanceof ThrowableDataError ||
|
|
59
|
+
value instanceof ThrowableRedirectError;
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import {
|
|
4
|
+
data,
|
|
5
|
+
redirect,
|
|
6
|
+
type ActionFunctionArgs,
|
|
7
|
+
type UNSAFE_DataWithResponseInit as DataWithResponseInit,
|
|
8
|
+
type LoaderFunctionArgs,
|
|
9
|
+
} from "react-router";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
isRouteError,
|
|
13
|
+
ReturnableDataError,
|
|
14
|
+
ThrowableDataError,
|
|
15
|
+
type AnyRouteError,
|
|
16
|
+
} from "./errors.ts";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Type-level plumbing.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
type Tagged = { readonly _tag: string };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Everything a failure branch rejects with. React Router routes a thrown
|
|
26
|
+
* `DataWithResponseInit` to the boundary as an `ErrorResponse`, and a thrown
|
|
27
|
+
* `Response` (including `redirect(...)`) as-is. Pinning every `Effect.fail` branch
|
|
28
|
+
* to this union keeps inference from fixing the error channel to the first one.
|
|
29
|
+
*/
|
|
30
|
+
type FailureResponse = DataWithResponseInit<unknown> | Response;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a handler may return, to *remap* a domain error:
|
|
34
|
+
* - a **library route error** — `Respond.early(value)` (recover), `Respond.throw(data)`
|
|
35
|
+
* or `Respond.redirect(url)` (throw) — which the library then processes; or
|
|
36
|
+
* - an **`Effect`** — `Effect.succeed(value)` to recover with `value`, or
|
|
37
|
+
* `Effect.fail(response)` to throw `response`.
|
|
38
|
+
*/
|
|
39
|
+
export type ErrorHandler<Err> = (
|
|
40
|
+
error: Err,
|
|
41
|
+
) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
42
|
+
|
|
43
|
+
/** The body of every `ReturnableDataError` raised directly in `E` (`Respond.early`). */
|
|
44
|
+
type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : never;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The recover contribution of a `Respond.early` raised directly in the loader.
|
|
48
|
+
* Guarded so a loader that never recovers directly doesn't leak
|
|
49
|
+
* `DataWithResponseInit<never>` into its return type.
|
|
50
|
+
*/
|
|
51
|
+
type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
|
|
52
|
+
? never
|
|
53
|
+
: DataWithResponseInit<ReturnableBodyOf<E>>;
|
|
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
|
+
/**
|
|
61
|
+
* What the registered handlers recover into loader/action data — derived from the
|
|
62
|
+
* handlers' returns, but only for handlers whose error can actually occur in `E`:
|
|
63
|
+
* - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
|
|
64
|
+
* - an `Effect.succeed(value)` recovers as `value`;
|
|
65
|
+
* - throwables, redirects and `Effect.fail(...)` contribute nothing.
|
|
66
|
+
*/
|
|
67
|
+
type RecoverOf<Handlers, E> = {
|
|
68
|
+
[Tag in keyof Handlers]: Handlers[Tag] extends (error: infer Err) => infer R
|
|
69
|
+
? [Extract<E, Err>] extends [never]
|
|
70
|
+
? never
|
|
71
|
+
:
|
|
72
|
+
| (R extends ReturnableDataError<infer B> ? DataWithResponseInit<B> : never)
|
|
73
|
+
| (R extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never)
|
|
74
|
+
: never;
|
|
75
|
+
}[keyof Handlers];
|
|
76
|
+
|
|
77
|
+
/** Shape every handler must satisfy: return a library route error or an `Effect` failing with a response. */
|
|
78
|
+
type ValidHandlers = Record<
|
|
79
|
+
string,
|
|
80
|
+
(error: never) => AnyRouteError | Effect.Effect<unknown, FailureResponse>
|
|
81
|
+
>;
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Internal runtime helpers.
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const internalServerError = () =>
|
|
88
|
+
Effect.fail<FailureResponse>(new Response("Internal Server Error", { status: 500 }));
|
|
89
|
+
|
|
90
|
+
/** The internal dispatch for a library route error: recover, throw, or redirect. */
|
|
91
|
+
const processRouteError = (
|
|
92
|
+
e: AnyRouteError,
|
|
93
|
+
): Effect.Effect<DataWithResponseInit<unknown>, FailureResponse> => {
|
|
94
|
+
if (e instanceof ReturnableDataError) return Effect.succeed(data(e.data, e.init));
|
|
95
|
+
if (e instanceof ThrowableDataError) return Effect.fail<FailureResponse>(data(e.data, e.init));
|
|
96
|
+
return Effect.fail<FailureResponse>(redirect(e.url, e.init));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Factory.
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
105
|
+
*
|
|
106
|
+
* Register a handler per domain error in `errorHandlers` (**annotate each handler's
|
|
107
|
+
* param** — the registered set and recover types are derived from the map). A
|
|
108
|
+
* handler *remaps* the error by returning either a library route error
|
|
109
|
+
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
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.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory({
|
|
120
|
+
* errorHandlers: {
|
|
121
|
+
* // throw → error boundary
|
|
122
|
+
* MyDomainError: (error: MyDomainError) =>
|
|
123
|
+
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
124
|
+
* // remap → recover via a library returnable
|
|
125
|
+
* FormError: (error: FormError) => Respond.early({ reply: error.reply }),
|
|
126
|
+
* },
|
|
127
|
+
* });
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export function makeLoaderOrActionFactory<const Handlers>(
|
|
131
|
+
config: { errorHandlers: Handlers },
|
|
132
|
+
// Validation: a handler that doesn't return a route error or a failing `Effect`
|
|
133
|
+
// makes this rest parameter required, forcing a compile error at the call.
|
|
134
|
+
..._validate: Handlers extends ValidHandlers
|
|
135
|
+
? []
|
|
136
|
+
: [eachHandlerMustReturnARouteErrorOrAnEffectFailingWithAResponse: ValidHandlers]
|
|
137
|
+
) {
|
|
138
|
+
/** Domain errors this factory has handlers for. */
|
|
139
|
+
type UserError = Extract<RegisteredError<Handlers>, Tagged>;
|
|
140
|
+
|
|
141
|
+
const isUserError = (e: unknown): e is UserError =>
|
|
142
|
+
typeof e === "object" &&
|
|
143
|
+
e !== null &&
|
|
144
|
+
"_tag" in e &&
|
|
145
|
+
(e as Tagged)._tag in (config.errorHandlers as object);
|
|
146
|
+
|
|
147
|
+
// Uniform call signature for dispatch (the per-tag handler types are narrower).
|
|
148
|
+
const userHandlers = config.errorHandlers as unknown as Record<string, ErrorHandler<UserError>>;
|
|
149
|
+
|
|
150
|
+
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
|
|
151
|
+
fn: (args: Args) => Effect.Effect<A, E>,
|
|
152
|
+
// The resolved value: the loader's own success, the body of any `Respond.early`
|
|
153
|
+
// raised directly, plus everything the registered (and reachable) handlers
|
|
154
|
+
// recover with.
|
|
155
|
+
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
|
|
156
|
+
return (args: Args) =>
|
|
157
|
+
// The internal channel is deliberately loose (`unknown` success); the outer
|
|
158
|
+
// cast restores the precise resolved type (computed from `E` and the handler
|
|
159
|
+
// map). Sound at runtime — the values produced are exactly those types.
|
|
160
|
+
Effect.runPromise(
|
|
161
|
+
fn(args).pipe(
|
|
162
|
+
// Catch the whole error channel and dispatch. The refinement is `e is E`
|
|
163
|
+
// (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
|
|
164
|
+
// overload crashes tsc over a generic `E`). Unregistered, non-respondable
|
|
165
|
+
// errors fall through to the 500 default.
|
|
166
|
+
Effect.catchIf(
|
|
167
|
+
(_e): _e is E => true,
|
|
168
|
+
(e): Effect.Effect<unknown, FailureResponse> => {
|
|
169
|
+
// Registered domain error → remap. A library-error return is processed
|
|
170
|
+
// by the internal dispatch; an `Effect` return is used as-is.
|
|
171
|
+
if (isUserError(e)) {
|
|
172
|
+
const out = userHandlers[e._tag](e);
|
|
173
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
174
|
+
}
|
|
175
|
+
// A library route error raised directly in the loader → recover/throw.
|
|
176
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
177
|
+
// Respondable → render its own response and throw it.
|
|
178
|
+
if (HttpServerRespondable.isRespondable(e)) {
|
|
179
|
+
return HttpServerRespondable.toResponse(e).pipe(
|
|
180
|
+
Effect.flatMap((res) =>
|
|
181
|
+
Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return internalServerError();
|
|
186
|
+
},
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
) as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
makeLoaderOrAction,
|
|
194
|
+
makeLoader: makeLoaderOrAction,
|
|
195
|
+
makeAction: makeLoaderOrAction,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
isRouteError,
|
|
3
|
+
Respond,
|
|
4
|
+
ReturnableDataError,
|
|
5
|
+
ThrowableDataError,
|
|
6
|
+
ThrowableRedirectError,
|
|
7
|
+
} from "./errors.ts";
|
|
8
|
+
export type { AnyRouteError } from "./errors.ts";
|
|
9
|
+
export { makeLoaderOrActionFactory } from "./factory.ts";
|
|
10
|
+
export type { ErrorHandler } from "./factory.ts";
|