react-router-effect 0.6.1 → 1.0.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 +46 -15
- package/dist/index.d.mts +84 -27
- package/dist/index.mjs +64 -18
- package/package.json +14 -14
- package/src/errors.ts +1 -1
- package/src/factory.ts +204 -69
- package/src/index.ts +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# react-router-effect
|
|
2
2
|
|
|
3
|
-
Wrap [React Router](https://reactrouter.com) framework-mode loaders and
|
|
3
|
+
Wrap [React Router](https://reactrouter.com) framework-mode loaders, actions, and middleware with
|
|
4
4
|
[Effect](https://effect.website), and get **typed, declarative error handling** for free.
|
|
5
5
|
|
|
6
|
-
Write your loader/action as an `Effect`. When it short-circuits with a tagged error,
|
|
7
|
-
library decides — based on handlers you register once — whether to **recover** (return data
|
|
6
|
+
Write your loader/action/middleware as an `Effect`. When it short-circuits with a tagged error,
|
|
7
|
+
the library decides — based on handlers you register once — whether to **recover** (return data
|
|
8
8
|
the component reads via `useLoaderData`) or **throw** (send it to the error boundary or issue
|
|
9
9
|
a redirect). The resolved type of every loader/action reflects exactly what it can return.
|
|
10
10
|
|
|
@@ -66,7 +66,7 @@ type-check with a message naming the missing service.)
|
|
|
66
66
|
```ts
|
|
67
67
|
// app/route.server.ts
|
|
68
68
|
import { Data, Effect } from "effect";
|
|
69
|
-
import {
|
|
69
|
+
import { makeEffectRouteFactory } from "react-router-effect";
|
|
70
70
|
|
|
71
71
|
class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
|
|
72
72
|
class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
|
|
@@ -80,7 +80,7 @@ type DomainErrors = FormError | BadInputError | DbError;
|
|
|
80
80
|
// is a *builder* — it receives `Respond` (the library's `early`/`throw`/`redirect`)
|
|
81
81
|
// so your handlers can recover/throw, and the factory hands back a single `Respond`
|
|
82
82
|
// for your routes to import (no auto-import ambiguity with a library export).
|
|
83
|
-
export const { makeLoader, makeAction, Respond } =
|
|
83
|
+
export const { makeLoader, makeAction, Respond } = makeEffectRouteFactory<DomainErrors>()(
|
|
84
84
|
(Respond) => ({
|
|
85
85
|
// App-specific helpers — merged onto the returned `Respond` (base helpers win):
|
|
86
86
|
respond: {
|
|
@@ -130,6 +130,36 @@ type LoaderData = Route.ComponentProps["loaderData"];
|
|
|
130
130
|
// ( BadInputError throws, so it never appears here )
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
+
### 3. Write middleware as effects
|
|
134
|
+
|
|
135
|
+
`makeMiddleware` wraps a [middleware](https://reactrouter.com/how-to/middleware) function the
|
|
136
|
+
same way, wired to the same `errorHandlers`. Pin it to the route's generated
|
|
137
|
+
`Route.MiddlewareFunction` — middleware has no separate args-only type, so the whole function
|
|
138
|
+
type is the type argument — or omit it to default to React Router's own `MiddlewareFunction`:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// app/routes/profile.ts
|
|
142
|
+
import { Effect } from "effect";
|
|
143
|
+
import { makeMiddleware, Respond } from "../route.server.ts";
|
|
144
|
+
import type { Route } from "./+types/profile";
|
|
145
|
+
|
|
146
|
+
export const middleware: Route.MiddlewareFunction[] = [
|
|
147
|
+
makeMiddleware<Route.MiddlewareFunction>(({ request }, next) =>
|
|
148
|
+
Effect.gen(function* () {
|
|
149
|
+
const user = yield* getUser(request); // may fail with FormError / BadInputError
|
|
150
|
+
if (!user) yield* Respond.redirect("/login"); // throw → redirect, same as a loader
|
|
151
|
+
return yield* next(); // run the rest of the chain
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
];
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`next` is handed to the effect as `() => Effect.Effect<Result, FailureResponse>` (React Router's
|
|
158
|
+
own `next: () => Promise<Result>`, wrapped) — call it with `yield*` to run downstream
|
|
159
|
+
loaders/middleware. A downstream throw (e.g. a nested loader's redirect) surfaces as a typed
|
|
160
|
+
failure rather than a rejected promise, so it flows through the same recover/throw handling as
|
|
161
|
+
a loader or action.
|
|
162
|
+
|
|
133
163
|
### Self-rendering domain errors
|
|
134
164
|
|
|
135
165
|
If a domain error implements `HttpServerRespondable`, you don't need to register a handler —
|
|
@@ -159,7 +189,7 @@ import { ManagedRuntime } from "effect";
|
|
|
159
189
|
export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
|
|
160
190
|
|
|
161
191
|
// app/route.server.ts
|
|
162
|
-
export const { makeLoader, makeAction } =
|
|
192
|
+
export const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
|
|
163
193
|
runtime: appRuntime,
|
|
164
194
|
errorHandlers: { ... },
|
|
165
195
|
}));
|
|
@@ -180,8 +210,8 @@ runtime provides type-checks, while requiring one it _doesn't_ is a compile erro
|
|
|
180
210
|
`errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
|
|
181
211
|
|
|
182
212
|
```ts
|
|
183
|
-
|
|
184
|
-
|
|
213
|
+
makeEffectRouteFactory()(() => ({ runtime }));
|
|
214
|
+
makeEffectRouteFactory()(() => ({}));
|
|
185
215
|
```
|
|
186
216
|
|
|
187
217
|
### Per-request services from middleware
|
|
@@ -216,7 +246,7 @@ export const middleware: Route.MiddlewareFunction[] = [
|
|
|
216
246
|
];
|
|
217
247
|
|
|
218
248
|
// app/route.server.ts — wire the same key into the factory:
|
|
219
|
-
export const { makeLoader } =
|
|
249
|
+
export const { makeLoader } = makeEffectRouteFactory<DomainErrors>()(() => ({
|
|
220
250
|
runtime: appRuntime,
|
|
221
251
|
requestContext,
|
|
222
252
|
}));
|
|
@@ -236,12 +266,13 @@ else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
|
|
|
236
266
|
|
|
237
267
|
## API
|
|
238
268
|
|
|
239
|
-
- **`
|
|
240
|
-
→ `{ makeLoader, makeAction, Respond }` (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
269
|
+
- **`makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond? }))`**
|
|
270
|
+
→ `{ makeLoader, makeAction, makeMiddleware, Respond }` (`makeLoader`/`makeAction` are the same
|
|
271
|
+
wrapper; `makeMiddleware` wraps a `Route.MiddlewareFunction`, defaulting to react-router's own
|
|
272
|
+
`MiddlewareFunction`). The config is a _builder_ that receives the base `Respond`; all its fields
|
|
273
|
+
are optional. The returned `Respond` is the base helpers merged with your `respond` extensions
|
|
274
|
+
(base helpers win). A non-domain error left in a handler's error channel — or a required service
|
|
275
|
+
that neither the `runtime` nor the `requestContext` provides — is a compile error.
|
|
245
276
|
- **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
|
|
246
277
|
effect context (`RouterContext<Context.Context<ReqServices>>`).
|
|
247
278
|
- **`Respond`** (returned from the factory) — `early` (recover), `throw`, `redirect`, plus any
|
package/dist/index.d.mts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { Context, Effect, ManagedRuntime } from "effect";
|
|
2
2
|
import { HttpServerRespondable } from "effect/unstable/http";
|
|
3
|
-
import { ActionFunctionArgs, LoaderFunctionArgs, RouterContext, UNSAFE_DataWithResponseInit } from "react-router";
|
|
4
|
-
|
|
3
|
+
import { ActionFunctionArgs, LoaderFunctionArgs, MiddlewareFunction, RouterContext, UNSAFE_DataWithResponseInit } from "react-router";
|
|
5
4
|
//#region src/errors.d.ts
|
|
6
|
-
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 & {
|
|
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 & {
|
|
7
6
|
readonly _tag: "ReturnableRouteError";
|
|
8
7
|
} & Readonly<A>;
|
|
9
8
|
/** Recoverable: short-circuits the loader but returns `data(...)` the component reads. */
|
|
@@ -11,7 +10,7 @@ declare class ReturnableDataError<D> extends ReturnableDataError_base<{
|
|
|
11
10
|
data: D;
|
|
12
11
|
init?: number | ResponseInit;
|
|
13
12
|
}> {}
|
|
14
|
-
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 & {
|
|
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 & {
|
|
15
14
|
readonly _tag: "ThrowableRouteError";
|
|
16
15
|
} & Readonly<A>;
|
|
17
16
|
/** Throwable: rejects with `data(...)` → boundary as an `ErrorResponse`. */
|
|
@@ -19,7 +18,7 @@ declare class ThrowableDataError<D> extends ThrowableDataError_base<{
|
|
|
19
18
|
data: D;
|
|
20
19
|
init?: number | ResponseInit;
|
|
21
20
|
}> {}
|
|
22
|
-
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 & {
|
|
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 & {
|
|
23
22
|
readonly _tag: "ThrowableRedirectError";
|
|
24
23
|
} & Readonly<A>;
|
|
25
24
|
/** Throwable: rejects with a redirect `Response`. */
|
|
@@ -36,7 +35,7 @@ type AnyRouteError = ReturnableDataError<unknown> | ThrowableDataError<unknown>
|
|
|
36
35
|
* extensions) so your app imports a single `Respond`:
|
|
37
36
|
*
|
|
38
37
|
* ```ts
|
|
39
|
-
* export const { makeLoader, Respond } =
|
|
38
|
+
* export const { makeLoader, Respond } = makeEffectRouteFactory<DomainErrors>()(
|
|
40
39
|
* (Respond) => ({
|
|
41
40
|
* respond: { formError: (reply) => new FormError({ reply }) },
|
|
42
41
|
* errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
|
|
@@ -45,8 +44,11 @@ type AnyRouteError = ReturnableDataError<unknown> | ThrowableDataError<unknown>
|
|
|
45
44
|
* ```
|
|
46
45
|
*/
|
|
47
46
|
declare const Respond: {
|
|
48
|
-
/** Recover: short-circuit and hand `value` to the component as loader/action data. */
|
|
49
|
-
|
|
47
|
+
/** Recover: short-circuit and hand `value` to the component as loader/action data. */
|
|
48
|
+
early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
|
|
49
|
+
/** Throw: short-circuit to the error boundary with `data(value, init)`. */
|
|
50
|
+
throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
|
|
51
|
+
/** Throw: short-circuit with a redirect `Response`. */
|
|
50
52
|
redirect: (url: string, init?: number | ResponseInit) => ThrowableRedirectError;
|
|
51
53
|
};
|
|
52
54
|
/** Narrows an unknown value to one of the library's route errors. */
|
|
@@ -83,6 +85,24 @@ type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : ne
|
|
|
83
85
|
type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_DataWithResponseInit<ReturnableBodyOf<E>>;
|
|
84
86
|
/** What a handler may return: a library route error or an `Effect` failing with a response. */
|
|
85
87
|
type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
88
|
+
/** A `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) — `(args, next) => ...`. */
|
|
89
|
+
type AnyMiddlewareFunction = (...args: never[]) => unknown;
|
|
90
|
+
/**
|
|
91
|
+
* The `args` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) is called
|
|
92
|
+
* with — inferred from the type argument so `makeMiddleware` can be pinned to a specific
|
|
93
|
+
* generated route's middleware type.
|
|
94
|
+
*/
|
|
95
|
+
type MiddlewareArgsOf<Middleware extends AnyMiddlewareFunction> = Parameters<Middleware>[0];
|
|
96
|
+
/**
|
|
97
|
+
* The `next` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) is called
|
|
98
|
+
* with — react-router's own `() => Promise<Result>`, not exported under its own name.
|
|
99
|
+
*/
|
|
100
|
+
type MiddlewareNextOf<Middleware extends AnyMiddlewareFunction> = Parameters<Middleware>[1];
|
|
101
|
+
/**
|
|
102
|
+
* The `Result` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) resolves
|
|
103
|
+
* with — i.e. what its `next()` resolves to, and what the middleware itself may return.
|
|
104
|
+
*/
|
|
105
|
+
type MiddlewareResultOf<Middleware extends AnyMiddlewareFunction> = MiddlewareNextOf<Middleware> extends (() => Promise<infer Result>) ? Result : never;
|
|
86
106
|
/**
|
|
87
107
|
* The inferred map of handler RETURN types, keyed by domain-error tag. Making the
|
|
88
108
|
* factory generic over *this* (rather than the whole handler map) is what lets
|
|
@@ -97,7 +117,7 @@ type HandlerReturns<DomainError extends Tagged> = Partial<Record<DomainError["_t
|
|
|
97
117
|
*/
|
|
98
118
|
type ErrorHandlers<DomainError extends Tagged, Returns> = { [Tag in keyof Returns]: (error: Extract<DomainError, {
|
|
99
119
|
readonly _tag: Tag;
|
|
100
|
-
}>) => Returns[Tag] };
|
|
120
|
+
}>) => Returns[Tag]; };
|
|
101
121
|
/**
|
|
102
122
|
* What the registered handlers recover into loader/action data — from each
|
|
103
123
|
* handler's return type, but only for handlers whose error can actually occur in
|
|
@@ -108,22 +128,26 @@ type ErrorHandlers<DomainError extends Tagged, Returns> = { [Tag in keyof Return
|
|
|
108
128
|
*/
|
|
109
129
|
type RecoverOf<DomainError extends Tagged, Returns, E> = { [Tag in keyof Returns]: [Extract<E, Extract<DomainError, {
|
|
110
130
|
readonly _tag: Tag;
|
|
111
|
-
}>>] extends [never] ? never : (Returns[Tag] extends ReturnableDataError<infer B> ? UNSAFE_DataWithResponseInit<B> : never) | (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never) }[keyof Returns];
|
|
131
|
+
}>>] extends [never] ? never : (Returns[Tag] extends ReturnableDataError<infer B> ? UNSAFE_DataWithResponseInit<B> : never) | (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never); }[keyof Returns];
|
|
112
132
|
/**
|
|
113
|
-
* Errors the library deals with on the
|
|
114
|
-
* handle them itself:
|
|
133
|
+
* Errors the library deals with on the handler's behalf — so the loader/action/
|
|
134
|
+
* middleware needn't handle them itself:
|
|
115
135
|
* - the app's **declared domain errors** (`DomainError`) — handled by a registered
|
|
116
136
|
* handler, or left to the 500 / auto-respond default;
|
|
117
137
|
* - **library route errors** raised directly via `Respond`;
|
|
118
|
-
* - anything that renders itself via **`HttpServerRespondable
|
|
138
|
+
* - anything that renders itself via **`HttpServerRespondable`**;
|
|
139
|
+
* - `AlsoHandled` — extra, call-site-specific already-handled errors. Only
|
|
140
|
+
* `makeMiddleware` uses this, to admit the `FailureResponse` its `next()`
|
|
141
|
+
* fails with (a downstream throw, already a finished response — the library
|
|
142
|
+
* re-throws it as-is, so the middleware needn't catch it just to re-raise it).
|
|
119
143
|
*/
|
|
120
|
-
type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
|
|
144
|
+
type LibraryHandled<DomainError, AlsoHandled = never> = DomainError | AnyRouteError | HttpServerRespondable.Respondable | AlsoHandled;
|
|
121
145
|
/**
|
|
122
|
-
* What remains in a
|
|
123
|
-
*
|
|
124
|
-
* errors. The loader/action must handle these itself (catch or map them).
|
|
146
|
+
* What remains in a handler's error channel that the library will NOT handle —
|
|
147
|
+
* i.e. service-specific errors the route consumes that aren't declared domain
|
|
148
|
+
* errors. The loader/action/middleware must handle these itself (catch or map them).
|
|
125
149
|
*/
|
|
126
|
-
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
150
|
+
type Unhandled<E, DomainError, AlsoHandled = never> = Exclude<E, LibraryHandled<DomainError, AlsoHandled>>;
|
|
127
151
|
/**
|
|
128
152
|
* A readable compile-time diagnostic. It's intersected onto the *effect return type*
|
|
129
153
|
* of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
|
|
@@ -146,12 +170,24 @@ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
|
146
170
|
type NameError<U> = U extends {
|
|
147
171
|
readonly _tag: infer Tag extends string;
|
|
148
172
|
} ? Tag : U;
|
|
149
|
-
|
|
173
|
+
/**
|
|
174
|
+
* True exactly when `T` is (or, for a union, includes) `any` — not `unknown`, not
|
|
175
|
+
* `never`, not any other type. An unrelated mistake elsewhere in the effect (a typo,
|
|
176
|
+
* a call with the wrong number of arguments, a genuine syntax error) commonly makes
|
|
177
|
+
* TypeScript infer `any` for `E`/`R` instead of reporting a clean type; running
|
|
178
|
+
* `Diagnose` on that `any` produces a confusing "unhandled errors: any" message that
|
|
179
|
+
* buries the real mistake. Guarding on this and bailing to `unknown` lets tsc's own,
|
|
180
|
+
* on-site error surface instead. (`1 & T` is `any` when `T` is `any` — including when
|
|
181
|
+
* `any` only appears as a union member, since intersection distributes over unions —
|
|
182
|
+
* and `0` is assignable to `any` but not to any concrete type built from `1 & T`.)
|
|
183
|
+
*/
|
|
184
|
+
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
185
|
+
type Diagnose<E, R, DomainError, Provided, AlsoHandled = never> = IsAny<E> extends true ? unknown : IsAny<R> extends true ? unknown : [Unhandled<E, DomainError, AlsoHandled>] extends [never] ? [Exclude<R, Provided>] extends [never] ? unknown : {
|
|
150
186
|
"react-router-effect": "This loader/action requires a service the factory does not provide. Add it to your runtime or requestContext, or handle it in the effect.";
|
|
151
187
|
missingRequirements: Exclude<R, Provided>;
|
|
152
188
|
} : {
|
|
153
189
|
"react-router-effect": "This loader/action can fail with an error the library does not handle. Catch it in the effect (e.g. Effect.catchTag), make it Respondable, or add it to your DomainError union.";
|
|
154
|
-
unhandledErrors: NameError<Unhandled<E, DomainError>>;
|
|
190
|
+
unhandledErrors: NameError<Unhandled<E, DomainError, AlsoHandled>>;
|
|
155
191
|
};
|
|
156
192
|
/**
|
|
157
193
|
* A React Router context key holding a per-request effect `Context.Context`. Set
|
|
@@ -182,7 +218,8 @@ type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends
|
|
|
182
218
|
*/
|
|
183
219
|
type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
|
|
184
220
|
/**
|
|
185
|
-
* Build `makeLoader` / `makeAction` for an application, wired to
|
|
221
|
+
* Build `makeLoader` / `makeAction` / `makeMiddleware` for an application, wired to
|
|
222
|
+
* its domain errors.
|
|
186
223
|
*
|
|
187
224
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
188
225
|
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
@@ -194,7 +231,7 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
194
231
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
195
232
|
*
|
|
196
233
|
* export const { makeLoader, makeAction, Respond } =
|
|
197
|
-
*
|
|
234
|
+
* makeEffectRouteFactory<DomainErrors>()((Respond) => ({
|
|
198
235
|
* errorHandlers: {
|
|
199
236
|
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
200
237
|
* MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
|
|
@@ -224,7 +261,7 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
224
261
|
* services directly — no per-call `Effect.provide`:
|
|
225
262
|
*
|
|
226
263
|
* ```ts
|
|
227
|
-
* const { makeLoader, makeAction } =
|
|
264
|
+
* const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
|
|
228
265
|
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
229
266
|
* errorHandlers: { ... },
|
|
230
267
|
* }));
|
|
@@ -237,8 +274,27 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
237
274
|
* }),
|
|
238
275
|
* );
|
|
239
276
|
* ```
|
|
277
|
+
*
|
|
278
|
+
* `makeMiddleware` wraps a `Route.MiddlewareFunction` the same way: write it as an
|
|
279
|
+
* effect, and register the same domain errors/handlers. Pin it to the route's
|
|
280
|
+
* generated `Route.MiddlewareFunction` (a type argument, since middleware has no
|
|
281
|
+
* separate args-only type) to type `args` and `next`'s result from that route; it
|
|
282
|
+
* defaults to React Router's own `MiddlewareFunction`. The `next` handed to the
|
|
283
|
+
* effect is `() => Effect.Effect<Result, FailureResponse>` — call it with `yield*`
|
|
284
|
+
* to run the rest of the chain, and a downstream throw (e.g. a nested loader's
|
|
285
|
+
* redirect) surfaces as a typed failure instead of a rejected promise:
|
|
286
|
+
*
|
|
287
|
+
* ```ts
|
|
288
|
+
* const middleware = makeMiddleware<Route.MiddlewareFunction>((args, next) =>
|
|
289
|
+
* Effect.gen(function* () {
|
|
290
|
+
* const user = yield* getUser(args); // may fail with a declared domain error
|
|
291
|
+
* if (!user) yield* Respond.redirect("/login");
|
|
292
|
+
* return yield* next();
|
|
293
|
+
* }),
|
|
294
|
+
* );
|
|
295
|
+
* ```
|
|
240
296
|
*/
|
|
241
|
-
declare function
|
|
297
|
+
declare function makeEffectRouteFactory<DomainError extends Tagged = never>(): <const Returns extends HandlerReturns<DomainError> = {}, RServices = never, ReqServices = never, const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {}>(builder: (respond: BaseRespond) => {
|
|
242
298
|
/**
|
|
243
299
|
* An optional handler per declared domain error, keyed by its tag. The keys
|
|
244
300
|
* autocomplete to your domain-error tags and each handler's `error` parameter
|
|
@@ -271,8 +327,9 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
271
327
|
*/
|
|
272
328
|
respond?: ExtraRespond;
|
|
273
329
|
}) => {
|
|
274
|
-
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E
|
|
275
|
-
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E
|
|
330
|
+
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
|
|
331
|
+
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
|
|
332
|
+
makeMiddleware: <Middleware extends MiddlewareFunction<any> = MiddlewareFunction, A = MiddlewareResultOf<Middleware>, E = never, R = never>(fn: (args: MiddlewareArgsOf<Middleware>, next: () => Effect.Effect<MiddlewareResultOf<Middleware>, FailureResponse>) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices, FailureResponse>) => (args: MiddlewareArgsOf<Middleware>, next: MiddlewareNextOf<Middleware>) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
|
|
276
333
|
Respond: {
|
|
277
334
|
early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
|
|
278
335
|
throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
|
|
@@ -280,4 +337,4 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
280
337
|
} & ExtraRespond;
|
|
281
338
|
};
|
|
282
339
|
//#endregion
|
|
283
|
-
export { type AnyRouteError, type ErrorHandler, type RequestContextKey, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError,
|
|
340
|
+
export { type AnyRouteError, type ErrorHandler, type RequestContextKey, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeEffectRouteFactory };
|
package/dist/index.mjs
CHANGED
|
@@ -15,7 +15,7 @@ var ThrowableRedirectError = class extends Data.TaggedError("ThrowableRedirectEr
|
|
|
15
15
|
* extensions) so your app imports a single `Respond`:
|
|
16
16
|
*
|
|
17
17
|
* ```ts
|
|
18
|
-
* export const { makeLoader, Respond } =
|
|
18
|
+
* export const { makeLoader, Respond } = makeEffectRouteFactory<DomainErrors>()(
|
|
19
19
|
* (Respond) => ({
|
|
20
20
|
* respond: { formError: (reply) => new FormError({ reply }) },
|
|
21
21
|
* errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
|
|
@@ -45,6 +45,12 @@ const isRouteError = (value) => value instanceof ReturnableDataError || value in
|
|
|
45
45
|
//#endregion
|
|
46
46
|
//#region src/factory.ts
|
|
47
47
|
const internalServerError = () => Effect.fail(new Response("Internal Server Error", { status: 500 }));
|
|
48
|
+
/**
|
|
49
|
+
* A raw `FailureResponse` — a downstream `next()`'s rejection, in `makeMiddleware` —
|
|
50
|
+
* is already a finished response; re-throwing it as-is (rather than 500ing it) lets
|
|
51
|
+
* a nested loader's redirect/thrown response pass back up through the middleware chain.
|
|
52
|
+
*/
|
|
53
|
+
const isFailureResponse = (e) => e instanceof Response || typeof e === "object" && e !== null && "type" in e && e.type === "DataWithResponseInit";
|
|
48
54
|
/** The internal dispatch for a library route error: recover, throw, or redirect. */
|
|
49
55
|
const processRouteError = (e) => {
|
|
50
56
|
if (e instanceof ReturnableDataError) return Effect.succeed(data(e.data, e.init));
|
|
@@ -52,7 +58,8 @@ const processRouteError = (e) => {
|
|
|
52
58
|
return Effect.fail(redirect(e.url, e.init));
|
|
53
59
|
};
|
|
54
60
|
/**
|
|
55
|
-
* Build `makeLoader` / `makeAction` for an application, wired to
|
|
61
|
+
* Build `makeLoader` / `makeAction` / `makeMiddleware` for an application, wired to
|
|
62
|
+
* its domain errors.
|
|
56
63
|
*
|
|
57
64
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
58
65
|
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
@@ -64,7 +71,7 @@ const processRouteError = (e) => {
|
|
|
64
71
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
65
72
|
*
|
|
66
73
|
* export const { makeLoader, makeAction, Respond } =
|
|
67
|
-
*
|
|
74
|
+
* makeEffectRouteFactory<DomainErrors>()((Respond) => ({
|
|
68
75
|
* errorHandlers: {
|
|
69
76
|
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
70
77
|
* MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
|
|
@@ -94,7 +101,7 @@ const processRouteError = (e) => {
|
|
|
94
101
|
* services directly — no per-call `Effect.provide`:
|
|
95
102
|
*
|
|
96
103
|
* ```ts
|
|
97
|
-
* const { makeLoader, makeAction } =
|
|
104
|
+
* const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
|
|
98
105
|
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
99
106
|
* errorHandlers: { ... },
|
|
100
107
|
* }));
|
|
@@ -107,8 +114,27 @@ const processRouteError = (e) => {
|
|
|
107
114
|
* }),
|
|
108
115
|
* );
|
|
109
116
|
* ```
|
|
117
|
+
*
|
|
118
|
+
* `makeMiddleware` wraps a `Route.MiddlewareFunction` the same way: write it as an
|
|
119
|
+
* effect, and register the same domain errors/handlers. Pin it to the route's
|
|
120
|
+
* generated `Route.MiddlewareFunction` (a type argument, since middleware has no
|
|
121
|
+
* separate args-only type) to type `args` and `next`'s result from that route; it
|
|
122
|
+
* defaults to React Router's own `MiddlewareFunction`. The `next` handed to the
|
|
123
|
+
* effect is `() => Effect.Effect<Result, FailureResponse>` — call it with `yield*`
|
|
124
|
+
* to run the rest of the chain, and a downstream throw (e.g. a nested loader's
|
|
125
|
+
* redirect) surfaces as a typed failure instead of a rejected promise:
|
|
126
|
+
*
|
|
127
|
+
* ```ts
|
|
128
|
+
* const middleware = makeMiddleware<Route.MiddlewareFunction>((args, next) =>
|
|
129
|
+
* Effect.gen(function* () {
|
|
130
|
+
* const user = yield* getUser(args); // may fail with a declared domain error
|
|
131
|
+
* if (!user) yield* Respond.redirect("/login");
|
|
132
|
+
* return yield* next();
|
|
133
|
+
* }),
|
|
134
|
+
* );
|
|
135
|
+
* ```
|
|
110
136
|
*/
|
|
111
|
-
function
|
|
137
|
+
function makeEffectRouteFactory() {
|
|
112
138
|
return function defineErrorHandlers(builder) {
|
|
113
139
|
const config = builder(Respond);
|
|
114
140
|
const runtime = config.runtime;
|
|
@@ -119,28 +145,48 @@ function makeLoaderOrActionFactory() {
|
|
|
119
145
|
};
|
|
120
146
|
const userHandlers = config.errorHandlers ?? {};
|
|
121
147
|
const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
|
|
148
|
+
/**
|
|
149
|
+
* Runs a handler's effect to completion: dispatches its whole error channel
|
|
150
|
+
* (registered domain errors, library route errors, respondables, or the 500
|
|
151
|
+
* default), provides the per-request context, and executes against the
|
|
152
|
+
* configured runtime. Shared by `makeLoader`/`makeAction` and `makeMiddleware`
|
|
153
|
+
* — they differ only in how they build the effect and what `context` they read.
|
|
154
|
+
*/
|
|
155
|
+
function runProgram(effect, context) {
|
|
156
|
+
const program = effect.pipe(Effect.catchIf((_e) => true, (e) => {
|
|
157
|
+
if (isUserError(e)) {
|
|
158
|
+
const out = userHandlers[e._tag](e);
|
|
159
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
160
|
+
}
|
|
161
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
162
|
+
if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
|
|
163
|
+
if (isFailureResponse(e)) return Effect.fail(e);
|
|
164
|
+
return internalServerError();
|
|
165
|
+
}));
|
|
166
|
+
const provided = requestContextKey ? Effect.provideContext(program, context.get(requestContextKey)) : program;
|
|
167
|
+
return runtime ? runtime.runPromise(provided) : Effect.runPromise(provided);
|
|
168
|
+
}
|
|
122
169
|
function makeLoaderOrAction(fn) {
|
|
123
170
|
const run = fn;
|
|
124
|
-
return (args) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const provided = requestContextKey ? Effect.provideContext(program, args.context.get(requestContextKey)) : program;
|
|
135
|
-
return runtime ? runtime.runPromise(provided) : Effect.runPromise(provided);
|
|
171
|
+
return (args) => runProgram(run(args), args.context);
|
|
172
|
+
}
|
|
173
|
+
function makeMiddleware(fn) {
|
|
174
|
+
const run = fn;
|
|
175
|
+
return (args, next) => {
|
|
176
|
+
const effectNext = () => Effect.tryPromise({
|
|
177
|
+
try: next,
|
|
178
|
+
catch: (e) => e
|
|
179
|
+
});
|
|
180
|
+
return runProgram(run(args, effectNext), args.context);
|
|
136
181
|
};
|
|
137
182
|
}
|
|
138
183
|
return {
|
|
139
184
|
makeLoader: makeLoaderOrAction,
|
|
140
185
|
makeAction: makeLoaderOrAction,
|
|
186
|
+
makeMiddleware,
|
|
141
187
|
Respond: Respond$1
|
|
142
188
|
};
|
|
143
189
|
};
|
|
144
190
|
}
|
|
145
191
|
//#endregion
|
|
146
|
-
export { ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError,
|
|
192
|
+
export { ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeEffectRouteFactory };
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://schemastore.org/package.json",
|
|
3
3
|
"name": "react-router-effect",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Wrap React Router
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Wrap React Router framework-mode route modules with Effect, with typed recover/throw/redirect error handling.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"action",
|
|
8
8
|
"effect",
|
|
9
9
|
"error-handling",
|
|
10
10
|
"loader",
|
|
11
|
+
"middleware",
|
|
11
12
|
"react-router",
|
|
12
13
|
"typescript"
|
|
13
14
|
],
|
|
@@ -36,27 +37,26 @@
|
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@changesets/changelog-github": "^0.7.0",
|
|
39
|
-
"@changesets/cli": "^2.31.
|
|
40
|
+
"@changesets/cli": "^2.31.1",
|
|
40
41
|
"@types/node": "^25.6.2",
|
|
41
42
|
"@types/react": "^19.2.17",
|
|
42
43
|
"@types/react-dom": "^19.2.3",
|
|
43
|
-
"@
|
|
44
|
-
"@
|
|
45
|
-
"@vitest/browser": "4.1.
|
|
46
|
-
"
|
|
47
|
-
"effect": "4.0.0-beta.88",
|
|
44
|
+
"@vitejs/plugin-react": "^6.0.4",
|
|
45
|
+
"@vitest/browser": "4.1.10",
|
|
46
|
+
"@vitest/browser-playwright": "4.1.10",
|
|
47
|
+
"effect": "4.0.0-beta.101",
|
|
48
48
|
"playwright": "1.60.0",
|
|
49
|
-
"react": "^19.2.
|
|
50
|
-
"react-dom": "^19.2.
|
|
51
|
-
"react-router": "^8.
|
|
52
|
-
"typescript": "^
|
|
49
|
+
"react": "^19.2.8",
|
|
50
|
+
"react-dom": "^19.2.8",
|
|
51
|
+
"react-router": "^8.3.0",
|
|
52
|
+
"typescript": "^7.0.2",
|
|
53
53
|
"vite-plus": "latest",
|
|
54
54
|
"vitest": "4.1.9",
|
|
55
55
|
"vitest-browser-react": "^2.2.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"effect": "^4.0.0-beta.
|
|
59
|
-
"react-router": "^7.16.0 || ^8.
|
|
58
|
+
"effect": "^4.0.0-beta.101",
|
|
59
|
+
"react-router": "^7.16.0 || ^8.3.0"
|
|
60
60
|
},
|
|
61
61
|
"devEngines": {
|
|
62
62
|
"packageManager": {
|
package/src/errors.ts
CHANGED
|
@@ -35,7 +35,7 @@ export type AnyRouteError =
|
|
|
35
35
|
* extensions) so your app imports a single `Respond`:
|
|
36
36
|
*
|
|
37
37
|
* ```ts
|
|
38
|
-
* export const { makeLoader, Respond } =
|
|
38
|
+
* export const { makeLoader, Respond } = makeEffectRouteFactory<DomainErrors>()(
|
|
39
39
|
* (Respond) => ({
|
|
40
40
|
* respond: { formError: (reply) => new FormError({ reply }) },
|
|
41
41
|
* errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
|
package/src/factory.ts
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
type ActionFunctionArgs,
|
|
7
7
|
type UNSAFE_DataWithResponseInit as DataWithResponseInit,
|
|
8
8
|
type LoaderFunctionArgs,
|
|
9
|
+
type MiddlewareFunction,
|
|
9
10
|
type RouterContext,
|
|
11
|
+
type RouterContextProvider,
|
|
10
12
|
} from "react-router";
|
|
11
13
|
|
|
12
14
|
import {
|
|
@@ -60,6 +62,29 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
|
|
|
60
62
|
/** What a handler may return: a library route error or an `Effect` failing with a response. */
|
|
61
63
|
type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
62
64
|
|
|
65
|
+
/** A `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) — `(args, next) => ...`. */
|
|
66
|
+
type AnyMiddlewareFunction = (...args: never[]) => unknown;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The `args` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) is called
|
|
70
|
+
* with — inferred from the type argument so `makeMiddleware` can be pinned to a specific
|
|
71
|
+
* generated route's middleware type.
|
|
72
|
+
*/
|
|
73
|
+
type MiddlewareArgsOf<Middleware extends AnyMiddlewareFunction> = Parameters<Middleware>[0];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `next` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) is called
|
|
77
|
+
* with — react-router's own `() => Promise<Result>`, not exported under its own name.
|
|
78
|
+
*/
|
|
79
|
+
type MiddlewareNextOf<Middleware extends AnyMiddlewareFunction> = Parameters<Middleware>[1];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The `Result` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) resolves
|
|
83
|
+
* with — i.e. what its `next()` resolves to, and what the middleware itself may return.
|
|
84
|
+
*/
|
|
85
|
+
type MiddlewareResultOf<Middleware extends AnyMiddlewareFunction> =
|
|
86
|
+
MiddlewareNextOf<Middleware> extends () => Promise<infer Result> ? Result : never;
|
|
87
|
+
|
|
63
88
|
/**
|
|
64
89
|
* The inferred map of handler RETURN types, keyed by domain-error tag. Making the
|
|
65
90
|
* factory generic over *this* (rather than the whole handler map) is what lets
|
|
@@ -96,21 +121,32 @@ type RecoverOf<DomainError extends Tagged, Returns, E> = {
|
|
|
96
121
|
}[keyof Returns];
|
|
97
122
|
|
|
98
123
|
/**
|
|
99
|
-
* Errors the library deals with on the
|
|
100
|
-
* handle them itself:
|
|
124
|
+
* Errors the library deals with on the handler's behalf — so the loader/action/
|
|
125
|
+
* middleware needn't handle them itself:
|
|
101
126
|
* - the app's **declared domain errors** (`DomainError`) — handled by a registered
|
|
102
127
|
* handler, or left to the 500 / auto-respond default;
|
|
103
128
|
* - **library route errors** raised directly via `Respond`;
|
|
104
|
-
* - anything that renders itself via **`HttpServerRespondable
|
|
129
|
+
* - anything that renders itself via **`HttpServerRespondable`**;
|
|
130
|
+
* - `AlsoHandled` — extra, call-site-specific already-handled errors. Only
|
|
131
|
+
* `makeMiddleware` uses this, to admit the `FailureResponse` its `next()`
|
|
132
|
+
* fails with (a downstream throw, already a finished response — the library
|
|
133
|
+
* re-throws it as-is, so the middleware needn't catch it just to re-raise it).
|
|
105
134
|
*/
|
|
106
|
-
type LibraryHandled<DomainError
|
|
135
|
+
type LibraryHandled<DomainError, AlsoHandled = never> =
|
|
136
|
+
| DomainError
|
|
137
|
+
| AnyRouteError
|
|
138
|
+
| HttpServerRespondable.Respondable
|
|
139
|
+
| AlsoHandled;
|
|
107
140
|
|
|
108
141
|
/**
|
|
109
|
-
* What remains in a
|
|
110
|
-
*
|
|
111
|
-
* errors. The loader/action must handle these itself (catch or map them).
|
|
142
|
+
* What remains in a handler's error channel that the library will NOT handle —
|
|
143
|
+
* i.e. service-specific errors the route consumes that aren't declared domain
|
|
144
|
+
* errors. The loader/action/middleware must handle these itself (catch or map them).
|
|
112
145
|
*/
|
|
113
|
-
type Unhandled<E, DomainError> = Exclude<
|
|
146
|
+
type Unhandled<E, DomainError, AlsoHandled = never> = Exclude<
|
|
147
|
+
E,
|
|
148
|
+
LibraryHandled<DomainError, AlsoHandled>
|
|
149
|
+
>;
|
|
114
150
|
|
|
115
151
|
// ---------------------------------------------------------------------------
|
|
116
152
|
// The call-site diagnostic.
|
|
@@ -137,17 +173,35 @@ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
|
137
173
|
*/
|
|
138
174
|
type NameError<U> = U extends { readonly _tag: infer Tag extends string } ? Tag : U;
|
|
139
175
|
|
|
140
|
-
|
|
141
|
-
|
|
176
|
+
/**
|
|
177
|
+
* True exactly when `T` is (or, for a union, includes) `any` — not `unknown`, not
|
|
178
|
+
* `never`, not any other type. An unrelated mistake elsewhere in the effect (a typo,
|
|
179
|
+
* a call with the wrong number of arguments, a genuine syntax error) commonly makes
|
|
180
|
+
* TypeScript infer `any` for `E`/`R` instead of reporting a clean type; running
|
|
181
|
+
* `Diagnose` on that `any` produces a confusing "unhandled errors: any" message that
|
|
182
|
+
* buries the real mistake. Guarding on this and bailing to `unknown` lets tsc's own,
|
|
183
|
+
* on-site error surface instead. (`1 & T` is `any` when `T` is `any` — including when
|
|
184
|
+
* `any` only appears as a union member, since intersection distributes over unions —
|
|
185
|
+
* and `0` is assignable to `any` but not to any concrete type built from `1 & T`.)
|
|
186
|
+
*/
|
|
187
|
+
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
188
|
+
|
|
189
|
+
type Diagnose<E, R, DomainError, Provided, AlsoHandled = never> =
|
|
190
|
+
IsAny<E> extends true
|
|
142
191
|
? unknown
|
|
143
|
-
:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
192
|
+
: IsAny<R> extends true
|
|
193
|
+
? unknown
|
|
194
|
+
: [Unhandled<E, DomainError, AlsoHandled>] extends [never]
|
|
195
|
+
? [Exclude<R, Provided>] extends [never]
|
|
196
|
+
? unknown
|
|
197
|
+
: {
|
|
198
|
+
"react-router-effect": "This loader/action requires a service the factory does not provide. Add it to your runtime or requestContext, or handle it in the effect.";
|
|
199
|
+
missingRequirements: Exclude<R, Provided>;
|
|
200
|
+
}
|
|
201
|
+
: {
|
|
202
|
+
"react-router-effect": "This loader/action can fail with an error the library does not handle. Catch it in the effect (e.g. Effect.catchTag), make it Respondable, or add it to your DomainError union.";
|
|
203
|
+
unhandledErrors: NameError<Unhandled<E, DomainError, AlsoHandled>>;
|
|
204
|
+
};
|
|
151
205
|
|
|
152
206
|
// ---------------------------------------------------------------------------
|
|
153
207
|
// Internal runtime helpers.
|
|
@@ -156,6 +210,18 @@ type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends
|
|
|
156
210
|
const internalServerError = () =>
|
|
157
211
|
Effect.fail<FailureResponse>(new Response("Internal Server Error", { status: 500 }));
|
|
158
212
|
|
|
213
|
+
/**
|
|
214
|
+
* A raw `FailureResponse` — a downstream `next()`'s rejection, in `makeMiddleware` —
|
|
215
|
+
* is already a finished response; re-throwing it as-is (rather than 500ing it) lets
|
|
216
|
+
* a nested loader's redirect/thrown response pass back up through the middleware chain.
|
|
217
|
+
*/
|
|
218
|
+
const isFailureResponse = (e: unknown): e is FailureResponse =>
|
|
219
|
+
e instanceof Response ||
|
|
220
|
+
(typeof e === "object" &&
|
|
221
|
+
e !== null &&
|
|
222
|
+
"type" in e &&
|
|
223
|
+
(e as { type?: unknown }).type === "DataWithResponseInit");
|
|
224
|
+
|
|
159
225
|
/** The internal dispatch for a library route error: recover, throw, or redirect. */
|
|
160
226
|
const processRouteError = (
|
|
161
227
|
e: AnyRouteError,
|
|
@@ -203,7 +269,8 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
203
269
|
// ---------------------------------------------------------------------------
|
|
204
270
|
|
|
205
271
|
/**
|
|
206
|
-
* Build `makeLoader` / `makeAction` for an application, wired to
|
|
272
|
+
* Build `makeLoader` / `makeAction` / `makeMiddleware` for an application, wired to
|
|
273
|
+
* its domain errors.
|
|
207
274
|
*
|
|
208
275
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
209
276
|
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
@@ -215,7 +282,7 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
215
282
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
216
283
|
*
|
|
217
284
|
* export const { makeLoader, makeAction, Respond } =
|
|
218
|
-
*
|
|
285
|
+
* makeEffectRouteFactory<DomainErrors>()((Respond) => ({
|
|
219
286
|
* errorHandlers: {
|
|
220
287
|
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
221
288
|
* MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
|
|
@@ -245,7 +312,7 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
245
312
|
* services directly — no per-call `Effect.provide`:
|
|
246
313
|
*
|
|
247
314
|
* ```ts
|
|
248
|
-
* const { makeLoader, makeAction } =
|
|
315
|
+
* const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
|
|
249
316
|
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
250
317
|
* errorHandlers: { ... },
|
|
251
318
|
* }));
|
|
@@ -258,8 +325,27 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
258
325
|
* }),
|
|
259
326
|
* );
|
|
260
327
|
* ```
|
|
328
|
+
*
|
|
329
|
+
* `makeMiddleware` wraps a `Route.MiddlewareFunction` the same way: write it as an
|
|
330
|
+
* effect, and register the same domain errors/handlers. Pin it to the route's
|
|
331
|
+
* generated `Route.MiddlewareFunction` (a type argument, since middleware has no
|
|
332
|
+
* separate args-only type) to type `args` and `next`'s result from that route; it
|
|
333
|
+
* defaults to React Router's own `MiddlewareFunction`. The `next` handed to the
|
|
334
|
+
* effect is `() => Effect.Effect<Result, FailureResponse>` — call it with `yield*`
|
|
335
|
+
* to run the rest of the chain, and a downstream throw (e.g. a nested loader's
|
|
336
|
+
* redirect) surfaces as a typed failure instead of a rejected promise:
|
|
337
|
+
*
|
|
338
|
+
* ```ts
|
|
339
|
+
* const middleware = makeMiddleware<Route.MiddlewareFunction>((args, next) =>
|
|
340
|
+
* Effect.gen(function* () {
|
|
341
|
+
* const user = yield* getUser(args); // may fail with a declared domain error
|
|
342
|
+
* if (!user) yield* Respond.redirect("/login");
|
|
343
|
+
* return yield* next();
|
|
344
|
+
* }),
|
|
345
|
+
* );
|
|
346
|
+
* ```
|
|
261
347
|
*/
|
|
262
|
-
export function
|
|
348
|
+
export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
|
|
263
349
|
return function defineErrorHandlers<
|
|
264
350
|
const Returns extends HandlerReturns<DomainError> = {},
|
|
265
351
|
RServices = never,
|
|
@@ -322,6 +408,65 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
322
408
|
const isUserError = (e: unknown): e is DomainError =>
|
|
323
409
|
typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
|
|
324
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Runs a handler's effect to completion: dispatches its whole error channel
|
|
413
|
+
* (registered domain errors, library route errors, respondables, or the 500
|
|
414
|
+
* default), provides the per-request context, and executes against the
|
|
415
|
+
* configured runtime. Shared by `makeLoader`/`makeAction` and `makeMiddleware`
|
|
416
|
+
* — they differ only in how they build the effect and what `context` they read.
|
|
417
|
+
*/
|
|
418
|
+
function runProgram<E>(
|
|
419
|
+
effect: Effect.Effect<unknown, E, RServices | ReqServices>,
|
|
420
|
+
context: Readonly<RouterContextProvider>,
|
|
421
|
+
): Promise<unknown> {
|
|
422
|
+
// The internal channel is deliberately loose (`unknown` success); the caller
|
|
423
|
+
// restores the precise resolved type (computed from `E` and the handler map).
|
|
424
|
+
// Sound at runtime — the values produced are exactly those types.
|
|
425
|
+
const program = effect.pipe(
|
|
426
|
+
// Catch the whole error channel and dispatch. The refinement is `e is E`
|
|
427
|
+
// (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
|
|
428
|
+
// overload crashes tsc over a generic `E`). A declared domain error with no
|
|
429
|
+
// handler (and not respondable) falls through to the 500 default.
|
|
430
|
+
Effect.catchIf(
|
|
431
|
+
(_e): _e is E => true,
|
|
432
|
+
(e): Effect.Effect<unknown, FailureResponse> => {
|
|
433
|
+
// Registered domain error → remap. A library-error return is processed
|
|
434
|
+
// by the internal dispatch; an `Effect` return is used as-is.
|
|
435
|
+
if (isUserError(e)) {
|
|
436
|
+
const out = userHandlers[e._tag](e);
|
|
437
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
438
|
+
}
|
|
439
|
+
// A library route error raised directly in the handler → recover/throw.
|
|
440
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
441
|
+
// Respondable → render its own response and throw it.
|
|
442
|
+
if (HttpServerRespondable.isRespondable(e)) {
|
|
443
|
+
return HttpServerRespondable.toResponse(e).pipe(
|
|
444
|
+
Effect.flatMap((res) =>
|
|
445
|
+
Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
|
|
446
|
+
),
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
// Already a finished response (e.g. a downstream `next()` rejection in
|
|
450
|
+
// `makeMiddleware`) → re-throw as-is, rather than 500ing it.
|
|
451
|
+
if (isFailureResponse(e)) return Effect.fail(e);
|
|
452
|
+
return internalServerError();
|
|
453
|
+
},
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
// Provide the per-request context (set by middleware) so `ReqServices` are
|
|
457
|
+
// satisfied, leaving only the runtime's `RServices` in the requirements.
|
|
458
|
+
// The cast is sound: `provideContext` removes `ReqServices`, and when no
|
|
459
|
+
// `requestContext` is configured `ReqServices` is `never` (nothing removed).
|
|
460
|
+
const provided = (
|
|
461
|
+
requestContextKey ? Effect.provideContext(program, context.get(requestContextKey)) : program
|
|
462
|
+
) as Effect.Effect<unknown, FailureResponse, RServices>;
|
|
463
|
+
// Run against the configured runtime so its services satisfy the effect's
|
|
464
|
+
// `R`; with no runtime, the effect requires nothing and runs standalone.
|
|
465
|
+
return runtime
|
|
466
|
+
? runtime.runPromise(provided)
|
|
467
|
+
: Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>);
|
|
468
|
+
}
|
|
469
|
+
|
|
325
470
|
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(
|
|
326
471
|
// `A`/`E`/`R` infer directly from the effect (naked, so inference stays robust).
|
|
327
472
|
// The diagnostic is intersected onto the effect's return type: when the effect
|
|
@@ -338,59 +483,49 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
338
483
|
const run = fn as unknown as (
|
|
339
484
|
args: Args,
|
|
340
485
|
) => Effect.Effect<unknown, E, RServices | ReqServices>;
|
|
341
|
-
return (args: Args) =>
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const provided = (
|
|
378
|
-
requestContextKey
|
|
379
|
-
? Effect.provideContext(program, args.context.get(requestContextKey))
|
|
380
|
-
: program
|
|
381
|
-
) as Effect.Effect<unknown, FailureResponse, RServices>;
|
|
382
|
-
// Run against the configured runtime so its services satisfy the effect's
|
|
383
|
-
// `R`; with no runtime, the effect requires nothing and runs standalone.
|
|
384
|
-
const result = runtime
|
|
385
|
-
? runtime.runPromise(provided)
|
|
386
|
-
: Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>);
|
|
387
|
-
return result as Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
|
|
486
|
+
return (args: Args) =>
|
|
487
|
+
runProgram(run(args), args.context) as Promise<
|
|
488
|
+
A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>
|
|
489
|
+
>;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function makeMiddleware<
|
|
493
|
+
Middleware extends MiddlewareFunction<any> = MiddlewareFunction,
|
|
494
|
+
A = MiddlewareResultOf<Middleware>,
|
|
495
|
+
E = never,
|
|
496
|
+
R = never,
|
|
497
|
+
>(
|
|
498
|
+
// Same shape as `makeLoaderOrAction`'s `fn`, but middleware also receives
|
|
499
|
+
// `next` — wrapped as an effect (rather than react-router's raw
|
|
500
|
+
// `() => Promise<Result>`) so downstream failures (e.g. a nested loader's
|
|
501
|
+
// redirect) surface as a typed `FailureResponse`, consistent with the rest
|
|
502
|
+
// of the library's error model.
|
|
503
|
+
fn: (
|
|
504
|
+
args: MiddlewareArgsOf<Middleware>,
|
|
505
|
+
next: () => Effect.Effect<MiddlewareResultOf<Middleware>, FailureResponse>,
|
|
506
|
+
) => Effect.Effect<A, E, R> &
|
|
507
|
+
Diagnose<E, R, DomainError, RServices | ReqServices, FailureResponse>,
|
|
508
|
+
): (
|
|
509
|
+
args: MiddlewareArgsOf<Middleware>,
|
|
510
|
+
next: MiddlewareNextOf<Middleware>,
|
|
511
|
+
) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>> {
|
|
512
|
+
const run = fn as unknown as (
|
|
513
|
+
args: MiddlewareArgsOf<Middleware>,
|
|
514
|
+
next: () => Effect.Effect<MiddlewareResultOf<Middleware>, FailureResponse>,
|
|
515
|
+
) => Effect.Effect<unknown, E, RServices | ReqServices>;
|
|
516
|
+
return (args, next) => {
|
|
517
|
+
const effectNext = () =>
|
|
518
|
+
Effect.tryPromise({ try: next, catch: (e) => e as FailureResponse });
|
|
519
|
+
return runProgram(run(args, effectNext), args.context) as Promise<
|
|
520
|
+
A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>
|
|
521
|
+
>;
|
|
388
522
|
};
|
|
389
523
|
}
|
|
390
524
|
|
|
391
525
|
return {
|
|
392
526
|
makeLoader: makeLoaderOrAction,
|
|
393
527
|
makeAction: makeLoaderOrAction,
|
|
528
|
+
makeMiddleware,
|
|
394
529
|
Respond,
|
|
395
530
|
};
|
|
396
531
|
};
|
package/src/index.ts
CHANGED
|
@@ -5,5 +5,5 @@ export {
|
|
|
5
5
|
ThrowableRedirectError,
|
|
6
6
|
} from "./errors.ts";
|
|
7
7
|
export type { AnyRouteError } from "./errors.ts";
|
|
8
|
-
export {
|
|
8
|
+
export { makeEffectRouteFactory } from "./factory.ts";
|
|
9
9
|
export type { ErrorHandler, RequestContextKey } from "./factory.ts";
|