react-router-effect 0.6.0 → 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 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
 
@@ -54,8 +54,10 @@ A **declared domain error** may be left unhandled:
54
54
 
55
55
  **Any other error** a route consumes — a service-specific error that isn't a declared domain
56
56
  error — **must be handled** in the loader/action (caught or mapped), or `makeLoader`/`makeAction`
57
- fails to type-check. This gives app-wide defaults for declared errors while enforcing explicit
58
- handling of feature/service-specific ones.
57
+ fails to type-check with a message naming the unhandled error(s). This gives app-wide defaults for
58
+ declared errors while enforcing explicit handling of feature/service-specific ones. (Likewise, an
59
+ effect that requires a service neither the `runtime` nor the `requestContext` provides fails to
60
+ type-check with a message naming the missing service.)
59
61
 
60
62
  ## Usage
61
63
 
@@ -64,7 +66,7 @@ handling of feature/service-specific ones.
64
66
  ```ts
65
67
  // app/route.server.ts
66
68
  import { Data, Effect } from "effect";
67
- import { makeLoaderOrActionFactory } from "react-router-effect";
69
+ import { makeEffectRouteFactory } from "react-router-effect";
68
70
 
69
71
  class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
70
72
  class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
@@ -78,7 +80,7 @@ type DomainErrors = FormError | BadInputError | DbError;
78
80
  // is a *builder* — it receives `Respond` (the library's `early`/`throw`/`redirect`)
79
81
  // so your handlers can recover/throw, and the factory hands back a single `Respond`
80
82
  // for your routes to import (no auto-import ambiguity with a library export).
81
- export const { makeLoader, makeAction, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
83
+ export const { makeLoader, makeAction, Respond } = makeEffectRouteFactory<DomainErrors>()(
82
84
  (Respond) => ({
83
85
  // App-specific helpers — merged onto the returned `Respond` (base helpers win):
84
86
  respond: {
@@ -128,6 +130,36 @@ type LoaderData = Route.ComponentProps["loaderData"];
128
130
  // ( BadInputError throws, so it never appears here )
129
131
  ```
130
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
+
131
163
  ### Self-rendering domain errors
132
164
 
133
165
  If a domain error implements `HttpServerRespondable`, you don't need to register a handler —
@@ -157,7 +189,7 @@ import { ManagedRuntime } from "effect";
157
189
  export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
158
190
 
159
191
  // app/route.server.ts
160
- export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
192
+ export const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
161
193
  runtime: appRuntime,
162
194
  errorHandlers: { ... },
163
195
  }));
@@ -178,8 +210,8 @@ runtime provides type-checks, while requiring one it _doesn't_ is a compile erro
178
210
  `errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
179
211
 
180
212
  ```ts
181
- makeLoaderOrActionFactory()(() => ({ runtime }));
182
- makeLoaderOrActionFactory()(() => ({}));
213
+ makeEffectRouteFactory()(() => ({ runtime }));
214
+ makeEffectRouteFactory()(() => ({}));
183
215
  ```
184
216
 
185
217
  ### Per-request services from middleware
@@ -214,7 +246,7 @@ export const middleware: Route.MiddlewareFunction[] = [
214
246
  ];
215
247
 
216
248
  // app/route.server.ts — wire the same key into the factory:
217
- export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
249
+ export const { makeLoader } = makeEffectRouteFactory<DomainErrors>()(() => ({
218
250
  runtime: appRuntime,
219
251
  requestContext,
220
252
  }));
@@ -234,12 +266,13 @@ else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
234
266
 
235
267
  ## API
236
268
 
237
- - **`makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond? }))`**
238
- → `{ makeLoader, makeAction, Respond }` (the two makers are the same wrapper). The config is a
239
- _builder_ that receives the base `Respond`; all its fields are optional. The returned `Respond` is
240
- the base helpers merged with your `respond` extensions (base helpers win). A non-domain error left
241
- in a loader/action's error channel — or a required service that neither the `runtime` nor the
242
- `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.
243
276
  - **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
244
277
  effect context (`RouterContext<Context.Context<ReqServices>>`).
245
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,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,67 @@ 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 loader's behalf — so the loader needn't
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).
143
+ */
144
+ type LibraryHandled<DomainError, AlsoHandled = never> = DomainError | AnyRouteError | HttpServerRespondable.Respondable | AlsoHandled;
145
+ /**
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).
149
+ */
150
+ type Unhandled<E, DomainError, AlsoHandled = never> = Exclude<E, LibraryHandled<DomainError, AlsoHandled>>;
151
+ /**
152
+ * A readable compile-time diagnostic. It's intersected onto the *effect return type*
153
+ * of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
154
+ * reports this message, and the offending types, *at the `makeLoader`/`makeAction`
155
+ * call* instead of the opaque "Expected 2 arguments". Resolves to `unknown` (a no-op
156
+ * intersection) when the effect is fully handled and all its requirements are provided:
157
+ *
158
+ * - **Unhandled error** — the effect can fail with something that isn't a declared
159
+ * domain error, a library route error, or respondable; `unhandledErrors` names them.
160
+ * - **Missing requirement** — the effect requires a service that neither the
161
+ * `runtime` nor the `requestContext` provides; `missingRequirements` names them.
162
+ *
163
+ * The message lives in an *inline* object literal (no named alias) so tsc/tsgo print
164
+ * its text verbatim rather than collapsing it to an alias name.
165
+ */
166
+ /**
167
+ * Names the unhandled errors for the diagnostic: a tagged error becomes its `_tag`
168
+ * (the string you'd pass to `Effect.catchTag`), anything untagged stays as its type.
119
169
  */
120
- type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
170
+ type NameError<U> = U extends {
171
+ readonly _tag: infer Tag extends string;
172
+ } ? Tag : U;
121
173
  /**
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).
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`.)
125
183
  */
126
- type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
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 : {
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.";
187
+ missingRequirements: Exclude<R, Provided>;
188
+ } : {
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.";
190
+ unhandledErrors: NameError<Unhandled<E, DomainError, AlsoHandled>>;
191
+ };
127
192
  /**
128
193
  * A React Router context key holding a per-request effect `Context.Context`. Set
129
194
  * it in middleware and pass it to the factory's `requestContext` — the runner
@@ -153,7 +218,8 @@ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
153
218
  */
154
219
  type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
155
220
  /**
156
- * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
221
+ * Build `makeLoader` / `makeAction` / `makeMiddleware` for an application, wired to
222
+ * its domain errors.
157
223
  *
158
224
  * Declare the app's **domain errors** as the type argument, then register an
159
225
  * *optional* handler per domain error in `errorHandlers`. The handler keys
@@ -165,7 +231,7 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
165
231
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
166
232
  *
167
233
  * export const { makeLoader, makeAction, Respond } =
168
- * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
234
+ * makeEffectRouteFactory<DomainErrors>()((Respond) => ({
169
235
  * errorHandlers: {
170
236
  * // `error` is typed as MyDomainError automatically. throw → error boundary
171
237
  * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
@@ -195,7 +261,7 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
195
261
  * services directly — no per-call `Effect.provide`:
196
262
  *
197
263
  * ```ts
198
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
264
+ * const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
199
265
  * runtime: getAppRuntime(), // provides Database, MyService, ...
200
266
  * errorHandlers: { ... },
201
267
  * }));
@@ -208,8 +274,27 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
208
274
  * }),
209
275
  * );
210
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
+ * ```
211
296
  */
212
- 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) => {
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) => {
213
298
  /**
214
299
  * An optional handler per declared domain error, keyed by its tag. The keys
215
300
  * autocomplete to your domain-error tags and each handler's `error` parameter
@@ -242,8 +327,9 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
242
327
  */
243
328
  respond?: ExtraRespond;
244
329
  }) => {
245
- makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E> | DirectRecover<E>>;
246
- makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E> | DirectRecover<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>>;
247
333
  Respond: {
248
334
  early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
249
335
  throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
@@ -251,4 +337,4 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
251
337
  } & ExtraRespond;
252
338
  };
253
339
  //#endregion
254
- export { type AnyRouteError, type ErrorHandler, type RequestContextKey, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
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 } = 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,27 +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;
122
- function makeLoaderOrAction(fn, ..._handle) {
123
- return (args) => {
124
- const program = fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
125
- if (isUserError(e)) {
126
- const out = userHandlers[e._tag](e);
127
- return isRouteError(out) ? processRouteError(out) : out;
128
- }
129
- if (isRouteError(e)) return processRouteError(e);
130
- if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
131
- return internalServerError();
132
- }));
133
- const provided = requestContextKey ? Effect.provideContext(program, args.context.get(requestContextKey)) : program;
134
- return runtime ? runtime.runPromise(provided) : Effect.runPromise(provided);
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
+ }
169
+ function makeLoaderOrAction(fn) {
170
+ const run = fn;
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);
135
181
  };
136
182
  }
137
183
  return {
138
184
  makeLoader: makeLoaderOrAction,
139
185
  makeAction: makeLoaderOrAction,
186
+ makeMiddleware,
140
187
  Respond: Respond$1
141
188
  };
142
189
  };
143
190
  }
144
191
  //#endregion
145
- export { ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
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.6.0",
5
- "description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
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.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.101",
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
54
  "vitest": "4.1.9",
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,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,87 @@ type RecoverOf<DomainError extends Tagged, Returns, E> = {
96
121
  }[keyof Returns];
97
122
 
98
123
  /**
99
- * Errors the library deals with on the loader's behalf — so the loader needn't
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> = DomainError | AnyRouteError | HttpServerRespondable.Respondable;
135
+ type LibraryHandled<DomainError, AlsoHandled = never> =
136
+ | DomainError
137
+ | AnyRouteError
138
+ | HttpServerRespondable.Respondable
139
+ | AlsoHandled;
107
140
 
108
141
  /**
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).
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<E, LibraryHandled<DomainError>>;
146
+ type Unhandled<E, DomainError, AlsoHandled = never> = Exclude<
147
+ E,
148
+ LibraryHandled<DomainError, AlsoHandled>
149
+ >;
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // The call-site diagnostic.
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * A readable compile-time diagnostic. It's intersected onto the *effect return type*
157
+ * of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
158
+ * reports this message, and the offending types, *at the `makeLoader`/`makeAction`
159
+ * call* instead of the opaque "Expected 2 arguments". Resolves to `unknown` (a no-op
160
+ * intersection) when the effect is fully handled and all its requirements are provided:
161
+ *
162
+ * - **Unhandled error** — the effect can fail with something that isn't a declared
163
+ * domain error, a library route error, or respondable; `unhandledErrors` names them.
164
+ * - **Missing requirement** — the effect requires a service that neither the
165
+ * `runtime` nor the `requestContext` provides; `missingRequirements` names them.
166
+ *
167
+ * The message lives in an *inline* object literal (no named alias) so tsc/tsgo print
168
+ * its text verbatim rather than collapsing it to an alias name.
169
+ */
170
+ /**
171
+ * Names the unhandled errors for the diagnostic: a tagged error becomes its `_tag`
172
+ * (the string you'd pass to `Effect.catchTag`), anything untagged stays as its type.
173
+ */
174
+ type NameError<U> = U extends { readonly _tag: infer Tag extends string } ? Tag : U;
175
+
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
191
+ ? unknown
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
+ };
114
205
 
115
206
  // ---------------------------------------------------------------------------
116
207
  // Internal runtime helpers.
@@ -119,6 +210,18 @@ type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
119
210
  const internalServerError = () =>
120
211
  Effect.fail<FailureResponse>(new Response("Internal Server Error", { status: 500 }));
121
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
+
122
225
  /** The internal dispatch for a library route error: recover, throw, or redirect. */
123
226
  const processRouteError = (
124
227
  e: AnyRouteError,
@@ -166,7 +269,8 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
166
269
  // ---------------------------------------------------------------------------
167
270
 
168
271
  /**
169
- * Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
272
+ * Build `makeLoader` / `makeAction` / `makeMiddleware` for an application, wired to
273
+ * its domain errors.
170
274
  *
171
275
  * Declare the app's **domain errors** as the type argument, then register an
172
276
  * *optional* handler per domain error in `errorHandlers`. The handler keys
@@ -178,7 +282,7 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
178
282
  * type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
179
283
  *
180
284
  * export const { makeLoader, makeAction, Respond } =
181
- * makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
285
+ * makeEffectRouteFactory<DomainErrors>()((Respond) => ({
182
286
  * errorHandlers: {
183
287
  * // `error` is typed as MyDomainError automatically. throw → error boundary
184
288
  * MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
@@ -208,7 +312,7 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
208
312
  * services directly — no per-call `Effect.provide`:
209
313
  *
210
314
  * ```ts
211
- * const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
315
+ * const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
212
316
  * runtime: getAppRuntime(), // provides Database, MyService, ...
213
317
  * errorHandlers: { ... },
214
318
  * }));
@@ -221,8 +325,27 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
221
325
  * }),
222
326
  * );
223
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
+ * ```
224
347
  */
225
- export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
348
+ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
226
349
  return function defineErrorHandlers<
227
350
  const Returns extends HandlerReturns<DomainError> = {},
228
351
  RServices = never,
@@ -285,78 +408,124 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
285
408
  const isUserError = (e: unknown): e is DomainError =>
286
409
  typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
287
410
 
288
- function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
289
- // The effect may require `RServices` (provided by the configured `runtime`)
290
- // and `ReqServices` (provided per-request from `requestContext`). Both are
291
- // `never` when their source isn't configured, so the effect must require
292
- // nothing extra.
293
- fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>,
294
- // If the effect can still fail with something the library won't handle — a
295
- // service-specific error that isn't a declared domain error, a library route
296
- // error, or respondable — this rest parameter becomes required and the call
297
- // fails to type-check, forcing the loader/action to handle it.
298
- ..._handle: [Unhandled<E, DomainError>] extends [never]
299
- ? []
300
- : [
301
- theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Unhandled<
302
- E,
303
- DomainError
304
- >,
305
- ]
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
+
470
+ function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(
471
+ // `A`/`E`/`R` infer directly from the effect (naked, so inference stays robust).
472
+ // The diagnostic is intersected onto the effect's return type: when the effect
473
+ // requires a service the factory doesn't provide (`R` ⊄ runtime+requestContext),
474
+ // or can fail with something the library won't handle, `Diagnose` becomes a
475
+ // message object the effect isn't assignable to — so this argument fails to
476
+ // type-check with a readable explanation, and the offending types, right here.
477
+ fn: (
478
+ args: Args,
479
+ ) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>,
306
480
  ): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>> {
307
- return (args: Args) => {
308
- // The internal channel is deliberately loose (`unknown` success); the outer
309
- // cast restores the precise resolved type (computed from `E` and the handler
310
- // map). Sound at runtime — the values produced are exactly those types.
311
- const program = fn(args).pipe(
312
- // Catch the whole error channel and dispatch. The refinement is `e is E`
313
- // (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
314
- // overload crashes tsc over a generic `E`). A declared domain error with
315
- // no handler (and not respondable) falls through to the 500 default.
316
- Effect.catchIf(
317
- (_e): _e is E => true,
318
- (e): Effect.Effect<unknown, FailureResponse> => {
319
- // Registered domain error → remap. A library-error return is processed
320
- // by the internal dispatch; an `Effect` return is used as-is.
321
- if (isUserError(e)) {
322
- const out = userHandlers[e._tag](e);
323
- return isRouteError(out) ? processRouteError(out) : out;
324
- }
325
- // A library route error raised directly in the loader → recover/throw.
326
- if (isRouteError(e)) return processRouteError(e);
327
- // Respondable → render its own response and throw it.
328
- if (HttpServerRespondable.isRespondable(e)) {
329
- return HttpServerRespondable.toResponse(e).pipe(
330
- Effect.flatMap((res) =>
331
- Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
332
- ),
333
- );
334
- }
335
- return internalServerError();
336
- },
337
- ),
338
- );
339
- // Provide the per-request context (set by middleware) so `ReqServices` are
340
- // satisfied, leaving only the runtime's `RServices` in the requirements.
341
- // The cast is sound: `provideContext` removes `ReqServices`, and when no
342
- // `requestContext` is configured `ReqServices` is `never` (nothing removed).
343
- const provided = (
344
- requestContextKey
345
- ? Effect.provideContext(program, args.context.get(requestContextKey))
346
- : program
347
- ) as Effect.Effect<unknown, FailureResponse, RServices>;
348
- // Run against the configured runtime so its services satisfy the effect's
349
- // `R`; with no runtime, the effect requires nothing and runs standalone.
350
- const result = runtime
351
- ? runtime.runPromise(provided)
352
- : Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>);
353
- return result as Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
481
+ // The diagnostic is irrelevant inside the body (it's `unknown` for any valid
482
+ // call); treat `fn` as the plain effect-returning function it is.
483
+ const run = fn as unknown as (
484
+ args: Args,
485
+ ) => Effect.Effect<unknown, E, RServices | ReqServices>;
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
+ >;
354
522
  };
355
523
  }
356
524
 
357
525
  return {
358
526
  makeLoader: makeLoaderOrAction,
359
527
  makeAction: makeLoaderOrAction,
528
+ makeMiddleware,
360
529
  Respond,
361
530
  };
362
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 { makeLoaderOrActionFactory } from "./factory.ts";
8
+ export { makeEffectRouteFactory } from "./factory.ts";
9
9
  export type { ErrorHandler, RequestContextKey } from "./factory.ts";