react-router-effect 0.6.1 → 1.0.1

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  # react-router-effect
2
2
 
3
- Wrap [React Router](https://reactrouter.com) framework-mode loaders and actions with
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, the
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 { makeLoaderOrActionFactory } from "react-router-effect";
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 } = makeLoaderOrActionFactory<DomainErrors>()(
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 } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
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
- makeLoaderOrActionFactory()(() => ({ runtime }));
184
- makeLoaderOrActionFactory()(() => ({}));
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 } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
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
- - **`makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond? }))`**
240
- → `{ makeLoader, makeAction, Respond }` (the two makers are the same wrapper). The config is a
241
- _builder_ that receives the base `Respond`; all its fields are optional. The returned `Respond` is
242
- the base helpers merged with your `respond` extensions (base helpers win). A non-domain error left
243
- in a loader/action's error channel — or a required service that neither the `runtime` nor the
244
- `requestContext` provides — is a compile error.
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 } = makeLoaderOrActionFactory<DomainErrors>()(
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. */early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>; /** Throw: short-circuit to the error boundary with `data(value, init)`. */
49
- throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>; /** Throw: short-circuit with a redirect `Response`. */
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,26 @@ 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
+ * Read through `Awaited` because react-router declares `next` as an *interface* with a
105
+ * call signature (`MiddlewareNextFunction`), and a route's own may resolve synchronously.
106
+ */
107
+ type MiddlewareResultOf<Middleware extends AnyMiddlewareFunction> = MiddlewareNextOf<Middleware> extends ((...args: never[]) => infer Result) ? Awaited<Result> : never;
86
108
  /**
87
109
  * The inferred map of handler RETURN types, keyed by domain-error tag. Making the
88
110
  * factory generic over *this* (rather than the whole handler map) is what lets
@@ -97,7 +119,7 @@ type HandlerReturns<DomainError extends Tagged> = Partial<Record<DomainError["_t
97
119
  */
98
120
  type ErrorHandlers<DomainError extends Tagged, Returns> = { [Tag in keyof Returns]: (error: Extract<DomainError, {
99
121
  readonly _tag: Tag;
100
- }>) => Returns[Tag] };
122
+ }>) => Returns[Tag]; };
101
123
  /**
102
124
  * What the registered handlers recover into loader/action data — from each
103
125
  * handler's return type, but only for handlers whose error can actually occur in
@@ -108,22 +130,26 @@ type ErrorHandlers<DomainError extends Tagged, Returns> = { [Tag in keyof Return
108
130
  */
109
131
  type RecoverOf<DomainError extends Tagged, Returns, E> = { [Tag in keyof Returns]: [Extract<E, Extract<DomainError, {
110
132
  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];
133
+ }>>] 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
134
  /**
113
- * Errors the library deals with on the loader's behalf — so the loader needn't
114
- * handle them itself:
135
+ * Errors the library deals with on the handler's behalf — so the loader/action/
136
+ * middleware needn't handle them itself:
115
137
  * - the app's **declared domain errors** (`DomainError`) — handled by a registered
116
138
  * handler, or left to the 500 / auto-respond default;
117
139
  * - **library route errors** raised directly via `Respond`;
118
- * - anything that renders itself via **`HttpServerRespondable`**.
140
+ * - anything that renders itself via **`HttpServerRespondable`**;
141
+ * - `AlsoHandled` — extra, call-site-specific already-handled errors. Only
142
+ * `makeMiddleware` uses this, to admit the `FailureResponse` its `next()`
143
+ * fails with (a downstream throw, already a finished response — the library
144
+ * re-throws it as-is, so the middleware needn't catch it just to re-raise it).
119
145
  */
120
- type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
146
+ type LibraryHandled<DomainError, AlsoHandled = never> = DomainError | AnyRouteError | HttpServerRespondable.Respondable | AlsoHandled;
121
147
  /**
122
- * What remains in a loader/action's error channel that the library will NOT handle
123
- * — i.e. service-specific errors the route consumes that aren't declared domain
124
- * errors. The loader/action must handle these itself (catch or map them).
148
+ * What remains in a handler's error channel that the library will NOT handle —
149
+ * i.e. service-specific errors the route consumes that aren't declared domain
150
+ * errors. The loader/action/middleware must handle these itself (catch or map them).
125
151
  */
126
- type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
152
+ type Unhandled<E, DomainError, AlsoHandled = never> = Exclude<E, LibraryHandled<DomainError, AlsoHandled>>;
127
153
  /**
128
154
  * A readable compile-time diagnostic. It's intersected onto the *effect return type*
129
155
  * of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
@@ -146,12 +172,24 @@ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
146
172
  type NameError<U> = U extends {
147
173
  readonly _tag: infer Tag extends string;
148
174
  } ? Tag : U;
149
- type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends [never] ? [Exclude<R, Provided>] extends [never] ? unknown : {
150
- "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.";
175
+ /**
176
+ * True exactly when `T` is (or, for a union, includes) `any` — not `unknown`, not
177
+ * `never`, not any other type. An unrelated mistake elsewhere in the effect (a typo,
178
+ * a call with the wrong number of arguments, a genuine syntax error) commonly makes
179
+ * TypeScript infer `any` for `E`/`R` instead of reporting a clean type; running
180
+ * `Diagnose` on that `any` produces a confusing "unhandled errors: any" message that
181
+ * buries the real mistake. Guarding on this and bailing to `unknown` lets tsc's own,
182
+ * on-site error surface instead. (`1 & T` is `any` when `T` is `any` — including when
183
+ * `any` only appears as a union member, since intersection distributes over unions —
184
+ * and `0` is assignable to `any` but not to any concrete type built from `1 & T`.)
185
+ */
186
+ type IsAny<T> = 0 extends 1 & T ? true : false;
187
+ 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 : {
188
+ "react-router-effect": "This loader/action/middleware requires a service the factory does not provide. Add it to your runtime or requestContext, or handle it in the effect.";
151
189
  missingRequirements: Exclude<R, Provided>;
152
190
  } : {
153
- "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>>;
191
+ "react-router-effect": "This loader/action/middleware 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.";
192
+ unhandledErrors: NameError<Unhandled<E, DomainError, AlsoHandled>>;
155
193
  };
156
194
  /**
157
195
  * A React Router context key holding a per-request effect `Context.Context`. Set
@@ -182,7 +220,8 @@ type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends
182
220
  */
183
221
  type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
184
222
  /**
185
- * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
223
+ * Build `makeLoader` / `makeAction` / `makeMiddleware` for an application, wired to
224
+ * its domain errors.
186
225
  *
187
226
  * Declare the app's **domain errors** as the type argument, then register an
188
227
  * *optional* handler per domain error in `errorHandlers`. The handler keys
@@ -194,7 +233,7 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
194
233
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
195
234
  *
196
235
  * export const { makeLoader, makeAction, Respond } =
197
- * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
236
+ * makeEffectRouteFactory<DomainErrors>()((Respond) => ({
198
237
  * errorHandlers: {
199
238
  * // `error` is typed as MyDomainError automatically. throw → error boundary
200
239
  * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
@@ -224,7 +263,7 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
224
263
  * services directly — no per-call `Effect.provide`:
225
264
  *
226
265
  * ```ts
227
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
266
+ * const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
228
267
  * runtime: getAppRuntime(), // provides Database, MyService, ...
229
268
  * errorHandlers: { ... },
230
269
  * }));
@@ -237,8 +276,27 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
237
276
  * }),
238
277
  * );
239
278
  * ```
279
+ *
280
+ * `makeMiddleware` wraps a `Route.MiddlewareFunction` the same way: write it as an
281
+ * effect, and register the same domain errors/handlers. Pin it to the route's
282
+ * generated `Route.MiddlewareFunction` (a type argument, since middleware has no
283
+ * separate args-only type) to type `args` and `next`'s result from that route; it
284
+ * defaults to React Router's own `MiddlewareFunction`. The `next` handed to the
285
+ * effect is `() => Effect.Effect<Result, FailureResponse>` — call it with `yield*`
286
+ * to run the rest of the chain, and a downstream throw (e.g. a nested loader's
287
+ * redirect) surfaces as a typed failure instead of a rejected promise:
288
+ *
289
+ * ```ts
290
+ * const middleware = makeMiddleware<Route.MiddlewareFunction>((args, next) =>
291
+ * Effect.gen(function* () {
292
+ * const user = yield* getUser(args); // may fail with a declared domain error
293
+ * if (!user) yield* Respond.redirect("/login");
294
+ * return yield* next();
295
+ * }),
296
+ * );
297
+ * ```
240
298
  */
241
- declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Returns extends HandlerReturns<DomainError> = {}, RServices = never, ReqServices = never, const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {}>(builder: (respond: BaseRespond) => {
299
+ 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
300
  /**
243
301
  * An optional handler per declared domain error, keyed by its tag. The keys
244
302
  * autocomplete to your domain-error tags and each handler's `error` parameter
@@ -271,8 +329,9 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
271
329
  */
272
330
  respond?: ExtraRespond;
273
331
  }) => {
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> | DirectRecover<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> | DirectRecover<E>>;
332
+ 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>>;
333
+ 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>>;
334
+ makeMiddleware: <Middleware extends AnyMiddlewareFunction = MiddlewareFunction, E = LibraryHandled<DomainError, FailureResponse>, R = RServices | ReqServices>(fn: (args: MiddlewareArgsOf<Middleware>, next: () => Effect.Effect<MiddlewareResultOf<Middleware>, FailureResponse>) => Effect.Effect<MiddlewareResultOf<Middleware> | void, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices, FailureResponse>) => (args: MiddlewareArgsOf<Middleware>, next: MiddlewareNextOf<Middleware>) => Promise<MiddlewareResultOf<Middleware> | undefined>;
276
335
  Respond: {
277
336
  early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
278
337
  throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
@@ -280,4 +339,4 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
280
339
  } & ExtraRespond;
281
340
  };
282
341
  //#endregion
283
- export { type AnyRouteError, type ErrorHandler, type RequestContextKey, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
342
+ 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 } = makeLoaderOrActionFactory<DomainErrors>()(
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 its domain errors.
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
- * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
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 } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
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 makeLoaderOrActionFactory() {
137
+ function makeEffectRouteFactory() {
112
138
  return function defineErrorHandlers(builder) {
113
139
  const config = builder(Respond);
114
140
  const runtime = config.runtime;
@@ -119,28 +145,66 @@ 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
- const program = run(args).pipe(Effect.catchIf((_e) => true, (e) => {
126
- if (isUserError(e)) {
127
- const out = userHandlers[e._tag](e);
128
- return isRouteError(out) ? processRouteError(out) : out;
129
- }
130
- if (isRouteError(e)) return processRouteError(e);
131
- if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
132
- return internalServerError();
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
+ /**
174
+ * Wraps a middleware function written as an effect. Pin it to the route's generated
175
+ * `Route.MiddlewareFunction` (a type argument, since middleware has no separate
176
+ * args-only type) to type `args`, `next` and the result from that route; it defaults
177
+ * to React Router's own `MiddlewareFunction`.
178
+ *
179
+ * The errors the library deals with for a loader/action it deals with here too —
180
+ * declared domain errors, library route errors and respondables — plus the
181
+ * `FailureResponse` `next()` fails with. Anything else must be handled in the effect,
182
+ * as must any service neither the `runtime` nor the `requestContext` provides.
183
+ *
184
+ * The wrapper is typed as the route's own middleware — `(args, next) =>
185
+ * Promise<Result | undefined>` — so it drops straight into a
186
+ * `Route.MiddlewareFunction[]`. A handler that recovers (`Respond.early`) resolves
187
+ * with `data(...)`, which react-router's server middleware pipeline turns into the
188
+ * short-circuit `Response`.
189
+ */
190
+ function makeMiddleware(fn) {
191
+ const run = fn;
192
+ return (args, next) => {
193
+ const effectNext = () => Effect.tryPromise({
194
+ try: next,
195
+ catch: (e) => e
196
+ });
197
+ const { context } = args;
198
+ return runProgram(run(args, effectNext), context);
136
199
  };
137
200
  }
138
201
  return {
139
202
  makeLoader: makeLoaderOrAction,
140
203
  makeAction: makeLoaderOrAction,
204
+ makeMiddleware,
141
205
  Respond: Respond$1
142
206
  };
143
207
  };
144
208
  }
145
209
  //#endregion
146
- export { ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
210
+ 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.6.1",
5
- "description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
4
+ "version": "1.0.1",
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.0",
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
- "@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",
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.102",
48
48
  "playwright": "1.60.0",
49
- "react": "^19.2.7",
50
- "react-dom": "^19.2.7",
51
- "react-router": "^8.0.0",
52
- "typescript": "^6.0.3",
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
- "vitest": "4.1.9",
54
+ "vitest": "4.1.10",
55
55
  "vitest-browser-react": "^2.2.0"
56
56
  },
57
57
  "peerDependencies": {
58
- "effect": "^4.0.0-beta.88",
59
- "react-router": "^7.16.0 || ^8.0.0"
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 } = makeLoaderOrActionFactory<DomainErrors>()(
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,31 @@ 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
+ * Read through `Awaited` because react-router declares `next` as an *interface* with a
85
+ * call signature (`MiddlewareNextFunction`), and a route's own may resolve synchronously.
86
+ */
87
+ type MiddlewareResultOf<Middleware extends AnyMiddlewareFunction> =
88
+ MiddlewareNextOf<Middleware> extends (...args: never[]) => infer Result ? Awaited<Result> : never;
89
+
63
90
  /**
64
91
  * The inferred map of handler RETURN types, keyed by domain-error tag. Making the
65
92
  * factory generic over *this* (rather than the whole handler map) is what lets
@@ -96,21 +123,32 @@ type RecoverOf<DomainError extends Tagged, Returns, E> = {
96
123
  }[keyof Returns];
97
124
 
98
125
  /**
99
- * Errors the library deals with on the loader's behalf — so the loader needn't
100
- * handle them itself:
126
+ * Errors the library deals with on the handler's behalf — so the loader/action/
127
+ * middleware needn't handle them itself:
101
128
  * - the app's **declared domain errors** (`DomainError`) — handled by a registered
102
129
  * handler, or left to the 500 / auto-respond default;
103
130
  * - **library route errors** raised directly via `Respond`;
104
- * - anything that renders itself via **`HttpServerRespondable`**.
131
+ * - anything that renders itself via **`HttpServerRespondable`**;
132
+ * - `AlsoHandled` — extra, call-site-specific already-handled errors. Only
133
+ * `makeMiddleware` uses this, to admit the `FailureResponse` its `next()`
134
+ * fails with (a downstream throw, already a finished response — the library
135
+ * re-throws it as-is, so the middleware needn't catch it just to re-raise it).
105
136
  */
106
- type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
137
+ type LibraryHandled<DomainError, AlsoHandled = never> =
138
+ | DomainError
139
+ | AnyRouteError
140
+ | HttpServerRespondable.Respondable
141
+ | AlsoHandled;
107
142
 
108
143
  /**
109
- * What remains in a loader/action's error channel that the library will NOT handle
110
- * — i.e. service-specific errors the route consumes that aren't declared domain
111
- * errors. The loader/action must handle these itself (catch or map them).
144
+ * What remains in a handler's error channel that the library will NOT handle —
145
+ * i.e. service-specific errors the route consumes that aren't declared domain
146
+ * errors. The loader/action/middleware must handle these itself (catch or map them).
112
147
  */
113
- type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
148
+ type Unhandled<E, DomainError, AlsoHandled = never> = Exclude<
149
+ E,
150
+ LibraryHandled<DomainError, AlsoHandled>
151
+ >;
114
152
 
115
153
  // ---------------------------------------------------------------------------
116
154
  // The call-site diagnostic.
@@ -137,17 +175,35 @@ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
137
175
  */
138
176
  type NameError<U> = U extends { readonly _tag: infer Tag extends string } ? Tag : U;
139
177
 
140
- type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends [never]
141
- ? [Exclude<R, Provided>] extends [never]
178
+ /**
179
+ * True exactly when `T` is (or, for a union, includes) `any` — not `unknown`, not
180
+ * `never`, not any other type. An unrelated mistake elsewhere in the effect (a typo,
181
+ * a call with the wrong number of arguments, a genuine syntax error) commonly makes
182
+ * TypeScript infer `any` for `E`/`R` instead of reporting a clean type; running
183
+ * `Diagnose` on that `any` produces a confusing "unhandled errors: any" message that
184
+ * buries the real mistake. Guarding on this and bailing to `unknown` lets tsc's own,
185
+ * on-site error surface instead. (`1 & T` is `any` when `T` is `any` — including when
186
+ * `any` only appears as a union member, since intersection distributes over unions —
187
+ * and `0` is assignable to `any` but not to any concrete type built from `1 & T`.)
188
+ */
189
+ type IsAny<T> = 0 extends 1 & T ? true : false;
190
+
191
+ type Diagnose<E, R, DomainError, Provided, AlsoHandled = never> =
192
+ IsAny<E> extends true
142
193
  ? unknown
143
- : {
144
- "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.";
145
- missingRequirements: Exclude<R, Provided>;
146
- }
147
- : {
148
- "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.";
149
- unhandledErrors: NameError<Unhandled<E, DomainError>>;
150
- };
194
+ : IsAny<R> extends true
195
+ ? unknown
196
+ : [Unhandled<E, DomainError, AlsoHandled>] extends [never]
197
+ ? [Exclude<R, Provided>] extends [never]
198
+ ? unknown
199
+ : {
200
+ "react-router-effect": "This loader/action/middleware requires a service the factory does not provide. Add it to your runtime or requestContext, or handle it in the effect.";
201
+ missingRequirements: Exclude<R, Provided>;
202
+ }
203
+ : {
204
+ "react-router-effect": "This loader/action/middleware 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.";
205
+ unhandledErrors: NameError<Unhandled<E, DomainError, AlsoHandled>>;
206
+ };
151
207
 
152
208
  // ---------------------------------------------------------------------------
153
209
  // Internal runtime helpers.
@@ -156,6 +212,18 @@ type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends
156
212
  const internalServerError = () =>
157
213
  Effect.fail<FailureResponse>(new Response("Internal Server Error", { status: 500 }));
158
214
 
215
+ /**
216
+ * A raw `FailureResponse` — a downstream `next()`'s rejection, in `makeMiddleware` —
217
+ * is already a finished response; re-throwing it as-is (rather than 500ing it) lets
218
+ * a nested loader's redirect/thrown response pass back up through the middleware chain.
219
+ */
220
+ const isFailureResponse = (e: unknown): e is FailureResponse =>
221
+ e instanceof Response ||
222
+ (typeof e === "object" &&
223
+ e !== null &&
224
+ "type" in e &&
225
+ (e as { type?: unknown }).type === "DataWithResponseInit");
226
+
159
227
  /** The internal dispatch for a library route error: recover, throw, or redirect. */
160
228
  const processRouteError = (
161
229
  e: AnyRouteError,
@@ -203,7 +271,8 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
203
271
  // ---------------------------------------------------------------------------
204
272
 
205
273
  /**
206
- * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
274
+ * Build `makeLoader` / `makeAction` / `makeMiddleware` for an application, wired to
275
+ * its domain errors.
207
276
  *
208
277
  * Declare the app's **domain errors** as the type argument, then register an
209
278
  * *optional* handler per domain error in `errorHandlers`. The handler keys
@@ -215,7 +284,7 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
215
284
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
216
285
  *
217
286
  * export const { makeLoader, makeAction, Respond } =
218
- * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
287
+ * makeEffectRouteFactory<DomainErrors>()((Respond) => ({
219
288
  * errorHandlers: {
220
289
  * // `error` is typed as MyDomainError automatically. throw → error boundary
221
290
  * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
@@ -245,7 +314,7 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
245
314
  * services directly — no per-call `Effect.provide`:
246
315
  *
247
316
  * ```ts
248
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
317
+ * const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
249
318
  * runtime: getAppRuntime(), // provides Database, MyService, ...
250
319
  * errorHandlers: { ... },
251
320
  * }));
@@ -258,8 +327,27 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
258
327
  * }),
259
328
  * );
260
329
  * ```
330
+ *
331
+ * `makeMiddleware` wraps a `Route.MiddlewareFunction` the same way: write it as an
332
+ * effect, and register the same domain errors/handlers. Pin it to the route's
333
+ * generated `Route.MiddlewareFunction` (a type argument, since middleware has no
334
+ * separate args-only type) to type `args` and `next`'s result from that route; it
335
+ * defaults to React Router's own `MiddlewareFunction`. The `next` handed to the
336
+ * effect is `() => Effect.Effect<Result, FailureResponse>` — call it with `yield*`
337
+ * to run the rest of the chain, and a downstream throw (e.g. a nested loader's
338
+ * redirect) surfaces as a typed failure instead of a rejected promise:
339
+ *
340
+ * ```ts
341
+ * const middleware = makeMiddleware<Route.MiddlewareFunction>((args, next) =>
342
+ * Effect.gen(function* () {
343
+ * const user = yield* getUser(args); // may fail with a declared domain error
344
+ * if (!user) yield* Respond.redirect("/login");
345
+ * return yield* next();
346
+ * }),
347
+ * );
348
+ * ```
261
349
  */
262
- export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
350
+ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
263
351
  return function defineErrorHandlers<
264
352
  const Returns extends HandlerReturns<DomainError> = {},
265
353
  RServices = never,
@@ -322,6 +410,65 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
322
410
  const isUserError = (e: unknown): e is DomainError =>
323
411
  typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
324
412
 
413
+ /**
414
+ * Runs a handler's effect to completion: dispatches its whole error channel
415
+ * (registered domain errors, library route errors, respondables, or the 500
416
+ * default), provides the per-request context, and executes against the
417
+ * configured runtime. Shared by `makeLoader`/`makeAction` and `makeMiddleware`
418
+ * — they differ only in how they build the effect and what `context` they read.
419
+ */
420
+ function runProgram<E>(
421
+ effect: Effect.Effect<unknown, E, RServices | ReqServices>,
422
+ context: Readonly<RouterContextProvider>,
423
+ ): Promise<unknown> {
424
+ // The internal channel is deliberately loose (`unknown` success); the caller
425
+ // restores the precise resolved type (computed from `E` and the handler map).
426
+ // Sound at runtime — the values produced are exactly those types.
427
+ const program = effect.pipe(
428
+ // Catch the whole error channel and dispatch. The refinement is `e is E`
429
+ // (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
430
+ // overload crashes tsc over a generic `E`). A declared domain error with no
431
+ // handler (and not respondable) falls through to the 500 default.
432
+ Effect.catchIf(
433
+ (_e): _e is E => true,
434
+ (e): Effect.Effect<unknown, FailureResponse> => {
435
+ // Registered domain error → remap. A library-error return is processed
436
+ // by the internal dispatch; an `Effect` return is used as-is.
437
+ if (isUserError(e)) {
438
+ const out = userHandlers[e._tag](e);
439
+ return isRouteError(out) ? processRouteError(out) : out;
440
+ }
441
+ // A library route error raised directly in the handler → recover/throw.
442
+ if (isRouteError(e)) return processRouteError(e);
443
+ // Respondable → render its own response and throw it.
444
+ if (HttpServerRespondable.isRespondable(e)) {
445
+ return HttpServerRespondable.toResponse(e).pipe(
446
+ Effect.flatMap((res) =>
447
+ Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
448
+ ),
449
+ );
450
+ }
451
+ // Already a finished response (e.g. a downstream `next()` rejection in
452
+ // `makeMiddleware`) → re-throw as-is, rather than 500ing it.
453
+ if (isFailureResponse(e)) return Effect.fail(e);
454
+ return internalServerError();
455
+ },
456
+ ),
457
+ );
458
+ // Provide the per-request context (set by middleware) so `ReqServices` are
459
+ // satisfied, leaving only the runtime's `RServices` in the requirements.
460
+ // The cast is sound: `provideContext` removes `ReqServices`, and when no
461
+ // `requestContext` is configured `ReqServices` is `never` (nothing removed).
462
+ const provided = (
463
+ requestContextKey ? Effect.provideContext(program, context.get(requestContextKey)) : program
464
+ ) as Effect.Effect<unknown, FailureResponse, RServices>;
465
+ // Run against the configured runtime so its services satisfy the effect's
466
+ // `R`; with no runtime, the effect requires nothing and runs standalone.
467
+ return runtime
468
+ ? runtime.runPromise(provided)
469
+ : Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>);
470
+ }
471
+
325
472
  function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(
326
473
  // `A`/`E`/`R` infer directly from the effect (naked, so inference stays robust).
327
474
  // The diagnostic is intersected onto the effect's return type: when the effect
@@ -338,59 +485,78 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
338
485
  const run = fn as unknown as (
339
486
  args: Args,
340
487
  ) => Effect.Effect<unknown, E, RServices | ReqServices>;
341
- return (args: Args) => {
342
- // The internal channel is deliberately loose (`unknown` success); the outer
343
- // cast restores the precise resolved type (computed from `E` and the handler
344
- // map). Sound at runtime — the values produced are exactly those types.
345
- const program = run(args).pipe(
346
- // Catch the whole error channel and dispatch. The refinement is `e is E`
347
- // (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
348
- // overload crashes tsc over a generic `E`). A declared domain error with no
349
- // handler (and not respondable) falls through to the 500 default.
350
- Effect.catchIf(
351
- (_e): _e is E => true,
352
- (e): Effect.Effect<unknown, FailureResponse> => {
353
- // Registered domain error → remap. A library-error return is processed
354
- // by the internal dispatch; an `Effect` return is used as-is.
355
- if (isUserError(e)) {
356
- const out = userHandlers[e._tag](e);
357
- return isRouteError(out) ? processRouteError(out) : out;
358
- }
359
- // A library route error raised directly in the loader → recover/throw.
360
- if (isRouteError(e)) return processRouteError(e);
361
- // Respondable → render its own response and throw it.
362
- if (HttpServerRespondable.isRespondable(e)) {
363
- return HttpServerRespondable.toResponse(e).pipe(
364
- Effect.flatMap((res) =>
365
- Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
366
- ),
367
- );
368
- }
369
- return internalServerError();
370
- },
371
- ),
372
- );
373
- // Provide the per-request context (set by middleware) so `ReqServices` are
374
- // satisfied, leaving only the runtime's `RServices` in the requirements.
375
- // The cast is sound: `provideContext` removes `ReqServices`, and when no
376
- // `requestContext` is configured `ReqServices` is `never` (nothing removed).
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>>;
488
+ return (args: Args) =>
489
+ runProgram(run(args), args.context) as Promise<
490
+ A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>
491
+ >;
492
+ }
493
+
494
+ /**
495
+ * Wraps a middleware function written as an effect. Pin it to the route's generated
496
+ * `Route.MiddlewareFunction` (a type argument, since middleware has no separate
497
+ * args-only type) to type `args`, `next` and the result from that route; it defaults
498
+ * to React Router's own `MiddlewareFunction`.
499
+ *
500
+ * The errors the library deals with for a loader/action it deals with here too —
501
+ * declared domain errors, library route errors and respondables — plus the
502
+ * `FailureResponse` `next()` fails with. Anything else must be handled in the effect,
503
+ * as must any service neither the `runtime` nor the `requestContext` provides.
504
+ *
505
+ * The wrapper is typed as the route's own middleware — `(args, next) =>
506
+ * Promise<Result | undefined>` — so it drops straight into a
507
+ * `Route.MiddlewareFunction[]`. A handler that recovers (`Respond.early`) resolves
508
+ * with `data(...)`, which react-router's server middleware pipeline turns into the
509
+ * short-circuit `Response`.
510
+ */
511
+ function makeMiddleware<
512
+ Middleware extends AnyMiddlewareFunction = MiddlewareFunction,
513
+ // A pinned call passes an explicit type argument, which makes the whole call
514
+ // explicit — TypeScript has no partial inference — so these defaults stand in
515
+ // for what would otherwise be inferred: exactly the errors the library handles
516
+ // and the services the factory provides.
517
+ E = LibraryHandled<DomainError, FailureResponse>,
518
+ R = RServices | ReqServices,
519
+ >(
520
+ // Same shape as `makeLoaderOrAction`'s `fn`, but middleware also receives
521
+ // `next` — wrapped as an effect (rather than react-router's raw
522
+ // `() => Promise<Result>`) so downstream failures (e.g. a nested loader's
523
+ // redirect) surface as a typed `FailureResponse`, consistent with the rest
524
+ // of the library's error model. The success type is fixed to the route's own
525
+ // `Result` (plus `void`), which is what react-router accepts back.
526
+ fn: (
527
+ args: MiddlewareArgsOf<Middleware>,
528
+ next: () => Effect.Effect<MiddlewareResultOf<Middleware>, FailureResponse>,
529
+ ) => Effect.Effect<MiddlewareResultOf<Middleware> | void, E, R> &
530
+ Diagnose<E, R, DomainError, RServices | ReqServices, FailureResponse>,
531
+ ): (
532
+ args: MiddlewareArgsOf<Middleware>,
533
+ next: MiddlewareNextOf<Middleware>,
534
+ ) => Promise<MiddlewareResultOf<Middleware> | undefined> {
535
+ // Inside the body the route's own arg/result types are opaque (they're
536
+ // `Parameters<Middleware>[...]` of an unresolved type parameter), so dispatch
537
+ // through the loose shape — as `makeLoaderOrAction` does with its diagnostic.
538
+ // Sound at runtime: the values produced are exactly the declared types.
539
+ const run = fn as unknown as (
540
+ args: MiddlewareArgsOf<Middleware>,
541
+ next: () => Effect.Effect<unknown, FailureResponse>,
542
+ ) => Effect.Effect<unknown, E, RServices | ReqServices>;
543
+ return (args, next) => {
544
+ const effectNext = () =>
545
+ Effect.tryPromise({
546
+ try: next as unknown as () => Promise<unknown>,
547
+ catch: (e) => e as FailureResponse,
548
+ });
549
+ const { context } = args as unknown as { context: Readonly<RouterContextProvider> };
550
+ return runProgram(run(args, effectNext), context) as Promise<
551
+ MiddlewareResultOf<Middleware> | undefined
552
+ >;
388
553
  };
389
554
  }
390
555
 
391
556
  return {
392
557
  makeLoader: makeLoaderOrAction,
393
558
  makeAction: makeLoaderOrAction,
559
+ makeMiddleware,
394
560
  Respond,
395
561
  };
396
562
  };
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 { makeLoaderOrActionFactory } from "./factory.ts";
8
+ export { makeEffectRouteFactory } from "./factory.ts";
9
9
  export type { ErrorHandler, RequestContextKey } from "./factory.ts";