react-router-effect 0.3.0 → 0.5.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 +62 -9
- package/dist/index.d.mts +76 -33
- package/dist/index.mjs +10 -8
- package/package.json +1 -1
- package/src/factory.ts +121 -75
- package/src/index.ts +1 -1
package/README.md
CHANGED
|
@@ -77,11 +77,11 @@ type DomainErrors = FormError | BadInputError | DbError;
|
|
|
77
77
|
// Curried: pin the domain errors, then the handler types are inferred.
|
|
78
78
|
export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
79
79
|
errorHandlers: {
|
|
80
|
+
// keys autocomplete to your domain-error tags; `error` is typed from its key.
|
|
80
81
|
// recover: short-circuit and hand the reply to the component
|
|
81
|
-
FormError: (error
|
|
82
|
+
FormError: (error) => baseRespond.early({ reply: error.reply }),
|
|
82
83
|
// throw: send to the error boundary
|
|
83
|
-
BadInputError: (error:
|
|
84
|
-
Effect.fail(new Response(error.message, { status: 400 })),
|
|
84
|
+
BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
|
|
85
85
|
},
|
|
86
86
|
});
|
|
87
87
|
|
|
@@ -92,8 +92,9 @@ export const Respond = {
|
|
|
92
92
|
};
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
> **
|
|
96
|
-
> the precise recover types are derived from
|
|
95
|
+
> **No annotations needed.** Handler keys autocomplete to your declared domain-error tags, each
|
|
96
|
+
> `error` parameter is typed from its key, and the precise recover types are derived from each
|
|
97
|
+
> handler's return.
|
|
97
98
|
|
|
98
99
|
### 2. Write loaders/actions as effects
|
|
99
100
|
|
|
@@ -177,12 +178,64 @@ makeLoaderOrActionFactory()({ runtime });
|
|
|
177
178
|
makeLoaderOrActionFactory()({});
|
|
178
179
|
```
|
|
179
180
|
|
|
181
|
+
### Per-request services from middleware
|
|
182
|
+
|
|
183
|
+
The runtime provides app-wide services. For **request-scoped** services — the current user, a
|
|
184
|
+
request id, a per-request transaction — use [middleware](https://reactrouter.com/how-to/middleware).
|
|
185
|
+
Middleware sets a fresh effect `Context` on a React Router context key each request; the factory's
|
|
186
|
+
`requestContext` reads it and provides those services to the loader/action:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// app/request-context.server.ts
|
|
190
|
+
import { Context } from "effect";
|
|
191
|
+
import { createContext } from "react-router";
|
|
192
|
+
import type { RequestContextKey } from "react-router-effect";
|
|
193
|
+
|
|
194
|
+
class RequestContext extends Context.Service<
|
|
195
|
+
RequestContext,
|
|
196
|
+
{
|
|
197
|
+
readonly userId: string;
|
|
198
|
+
}
|
|
199
|
+
>()("app/RequestContext") {}
|
|
200
|
+
|
|
201
|
+
// `requestContext` is a plain React Router context key — `createContext` is RR's own.
|
|
202
|
+
export const requestContext: RequestContextKey<RequestContext> = createContext();
|
|
203
|
+
|
|
204
|
+
// app/routes/profile.ts — middleware sets a fresh value per request:
|
|
205
|
+
export const middleware: Route.MiddlewareFunction[] = [
|
|
206
|
+
({ context, request }, next) => {
|
|
207
|
+
context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
|
|
208
|
+
return next();
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
// app/route.server.ts — wire the same key into the factory:
|
|
213
|
+
export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
214
|
+
runtime: appRuntime,
|
|
215
|
+
requestContext,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// the loader requires RequestContext directly — no provide, fresh each request:
|
|
219
|
+
export const loader = makeLoader(() =>
|
|
220
|
+
Effect.gen(function* () {
|
|
221
|
+
const { userId } = yield* RequestContext;
|
|
222
|
+
return { userId };
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Effects may now require both the runtime's services and the request context's; requiring anything
|
|
228
|
+
else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
|
|
229
|
+
`RouterContext<Context.Context<ReqServices>>` — sugar for annotating the key, nothing more.
|
|
230
|
+
|
|
180
231
|
## API
|
|
181
232
|
|
|
182
|
-
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime? })`** →
|
|
183
|
-
`{ makeLoader, makeAction }` (both are the same wrapper).
|
|
184
|
-
non-domain error left in a loader/action's error channel — or a required service the
|
|
185
|
-
|
|
233
|
+
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime?, requestContext? })`** →
|
|
234
|
+
`{ makeLoader, makeAction }` (both are the same wrapper). All config fields are optional. A
|
|
235
|
+
non-domain error left in a loader/action's error channel — or a required service that neither the
|
|
236
|
+
`runtime` nor the `requestContext` provides — is a compile error.
|
|
237
|
+
- **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
|
|
238
|
+
effect context (`RouterContext<Context.Context<ReqServices>>`).
|
|
186
239
|
- **`Respond`** — `early` (recover), `throw`, `redirect`.
|
|
187
240
|
- **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
|
|
188
241
|
route errors, and **`isRouteError`** to narrow them.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Effect, ManagedRuntime } from "effect";
|
|
1
|
+
import { Context, Effect, ManagedRuntime } from "effect";
|
|
2
2
|
import { HttpServerRespondable } from "effect/unstable/http";
|
|
3
|
-
import { ActionFunctionArgs, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } from "react-router";
|
|
3
|
+
import { ActionFunctionArgs, LoaderFunctionArgs, RouterContext, UNSAFE_DataWithResponseInit } from "react-router";
|
|
4
4
|
|
|
5
5
|
//#region src/errors.d.ts
|
|
6
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 & {
|
|
@@ -76,14 +76,34 @@ type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : ne
|
|
|
76
76
|
* `DataWithResponseInit<never>` into its return type.
|
|
77
77
|
*/
|
|
78
78
|
type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_DataWithResponseInit<ReturnableBodyOf<E>>;
|
|
79
|
+
/** What a handler may return: a library route error or an `Effect` failing with a response. */
|
|
80
|
+
type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
79
81
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
+
* The inferred map of handler RETURN types, keyed by domain-error tag. Making the
|
|
83
|
+
* factory generic over *this* (rather than the whole handler map) is what lets
|
|
84
|
+
* `errorHandlers` contextually type each handler's `error` param from its key —
|
|
85
|
+
* no annotation needed — while still capturing precise return types for recovery.
|
|
86
|
+
*/
|
|
87
|
+
type HandlerReturns<DomainError extends Tagged> = Partial<Record<DomainError["_tag"], HandlerReturn>>;
|
|
88
|
+
/**
|
|
89
|
+
* The `errorHandlers` field shape for a given return map: one entry per registered
|
|
90
|
+
* tag, whose `error` is the matching domain error (so it autocompletes and types
|
|
91
|
+
* itself) and whose return is the precise, inferred `Returns[Tag]`.
|
|
92
|
+
*/
|
|
93
|
+
type ErrorHandlers<DomainError extends Tagged, Returns> = { [Tag in keyof Returns]: (error: Extract<DomainError, {
|
|
94
|
+
readonly _tag: Tag;
|
|
95
|
+
}>) => Returns[Tag] };
|
|
96
|
+
/**
|
|
97
|
+
* What the registered handlers recover into loader/action data — from each
|
|
98
|
+
* handler's return type, but only for handlers whose error can actually occur in
|
|
99
|
+
* `E`:
|
|
82
100
|
* - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
|
|
83
101
|
* - an `Effect.succeed(value)` recovers as `value`;
|
|
84
102
|
* - throwables, redirects and `Effect.fail(...)` contribute nothing.
|
|
85
103
|
*/
|
|
86
|
-
type RecoverOf<
|
|
104
|
+
type RecoverOf<DomainError extends Tagged, Returns, E> = { [Tag in keyof Returns]: [Extract<E, Extract<DomainError, {
|
|
105
|
+
readonly _tag: Tag;
|
|
106
|
+
}>>] 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];
|
|
87
107
|
/**
|
|
88
108
|
* Errors the library deals with on the loader's behalf — so the loader needn't
|
|
89
109
|
* handle them itself:
|
|
@@ -100,36 +120,49 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
|
|
|
100
120
|
*/
|
|
101
121
|
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
102
122
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
123
|
+
* A React Router context key holding a per-request effect `Context.Context`. Set
|
|
124
|
+
* it in middleware and pass it to the factory's `requestContext` — the runner
|
|
125
|
+
* reads it on every request and provides its services to the loader/action.
|
|
126
|
+
*
|
|
127
|
+
* It's just a `RouterContext`; create one with React Router's `createContext`:
|
|
128
|
+
*
|
|
129
|
+
* ```ts
|
|
130
|
+
* import { createContext } from "react-router";
|
|
131
|
+
* import { Context } from "effect";
|
|
132
|
+
* import type { RequestContextKey } from "react-router-effect";
|
|
133
|
+
*
|
|
134
|
+
* class RequestContext extends Context.Service<RequestContext, {
|
|
135
|
+
* readonly userId: string;
|
|
136
|
+
* }>()("app/RequestContext") {}
|
|
137
|
+
*
|
|
138
|
+
* export const requestContext: RequestContextKey<RequestContext> = createContext();
|
|
139
|
+
*
|
|
140
|
+
* // middleware:
|
|
141
|
+
* export const middleware: Route.MiddlewareFunction[] = [
|
|
142
|
+
* ({ context, request }, next) => {
|
|
143
|
+
* context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
|
|
144
|
+
* return next();
|
|
145
|
+
* },
|
|
146
|
+
* ];
|
|
147
|
+
* ```
|
|
115
148
|
*/
|
|
116
|
-
type
|
|
149
|
+
type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
|
|
117
150
|
/**
|
|
118
151
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
119
152
|
*
|
|
120
153
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
121
|
-
* *optional* handler per domain error in `errorHandlers
|
|
122
|
-
*
|
|
123
|
-
*
|
|
154
|
+
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
155
|
+
* autocomplete to your domain-error tags and each `error` parameter is typed from
|
|
156
|
+
* its key — no annotation needed. It's curried so you can pin the domain errors
|
|
157
|
+
* while the handler return types are still inferred:
|
|
124
158
|
*
|
|
125
159
|
* ```ts
|
|
126
160
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
127
161
|
*
|
|
128
162
|
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
129
163
|
* errorHandlers: {
|
|
130
|
-
* // throw → error boundary
|
|
131
|
-
* MyDomainError: (error:
|
|
132
|
-
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
164
|
+
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
165
|
+
* MyDomainError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
|
|
133
166
|
* // DbError has no handler → falls through to the 500 default.
|
|
134
167
|
* },
|
|
135
168
|
* });
|
|
@@ -165,13 +198,15 @@ type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] e
|
|
|
165
198
|
* );
|
|
166
199
|
* ```
|
|
167
200
|
*/
|
|
168
|
-
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const
|
|
201
|
+
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Returns extends HandlerReturns<DomainError> = {}, RServices = never, ReqServices = never>(config: {
|
|
169
202
|
/**
|
|
170
|
-
* An optional handler per declared domain error
|
|
171
|
-
*
|
|
172
|
-
*
|
|
203
|
+
* An optional handler per declared domain error, keyed by its tag. The keys
|
|
204
|
+
* autocomplete to your domain-error tags and each handler's `error` parameter
|
|
205
|
+
* is typed automatically — no annotation needed. A handler *remaps* the error
|
|
206
|
+
* by returning a library route error (`Respond.early` / `throw` / `redirect`)
|
|
207
|
+
* or an `Effect` (`Effect.succeed`/`fail`). Omit entirely to register none.
|
|
173
208
|
*/
|
|
174
|
-
errorHandlers?:
|
|
209
|
+
errorHandlers?: ErrorHandlers<DomainError, Returns>;
|
|
175
210
|
/**
|
|
176
211
|
* The app runtime that provides services to loader/action effects. When
|
|
177
212
|
* set, effects may require its services (`RServices`, inferred from here)
|
|
@@ -179,9 +214,17 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
179
214
|
* omitted, `RServices` is `never` and effects must require nothing.
|
|
180
215
|
*/
|
|
181
216
|
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
217
|
+
/**
|
|
218
|
+
* A React Router context key (a {@link RequestContextKey}) holding a
|
|
219
|
+
* per-request effect `Context.Context`. Middleware sets it for each request;
|
|
220
|
+
* the runner reads `args.context.get(requestContext)` and provides those
|
|
221
|
+
* services to the effect. Loader/action effects may then require
|
|
222
|
+
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
223
|
+
*/
|
|
224
|
+
requestContext?: RequestContextKey<ReqServices>;
|
|
225
|
+
}) => {
|
|
226
|
+
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>>;
|
|
227
|
+
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>>;
|
|
185
228
|
};
|
|
186
229
|
//#endregion
|
|
187
|
-
export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
|
230
|
+
export { type AnyRouteError, type ErrorHandler, type RequestContextKey, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
package/dist/index.mjs
CHANGED
|
@@ -52,18 +52,18 @@ const processRouteError = (e) => {
|
|
|
52
52
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
53
53
|
*
|
|
54
54
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
55
|
-
* *optional* handler per domain error in `errorHandlers
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
56
|
+
* autocomplete to your domain-error tags and each `error` parameter is typed from
|
|
57
|
+
* its key — no annotation needed. It's curried so you can pin the domain errors
|
|
58
|
+
* while the handler return types are still inferred:
|
|
58
59
|
*
|
|
59
60
|
* ```ts
|
|
60
61
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
61
62
|
*
|
|
62
63
|
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
63
64
|
* errorHandlers: {
|
|
64
|
-
* // throw → error boundary
|
|
65
|
-
* MyDomainError: (error:
|
|
66
|
-
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
65
|
+
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
66
|
+
* MyDomainError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
|
|
67
67
|
* // DbError has no handler → falls through to the 500 default.
|
|
68
68
|
* },
|
|
69
69
|
* });
|
|
@@ -100,8 +100,9 @@ const processRouteError = (e) => {
|
|
|
100
100
|
* ```
|
|
101
101
|
*/
|
|
102
102
|
function makeLoaderOrActionFactory() {
|
|
103
|
-
return function defineErrorHandlers(config
|
|
103
|
+
return function defineErrorHandlers(config) {
|
|
104
104
|
const runtime = config.runtime;
|
|
105
|
+
const requestContextKey = config.requestContext;
|
|
105
106
|
const userHandlers = config.errorHandlers ?? {};
|
|
106
107
|
const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
|
|
107
108
|
function makeLoaderOrAction(fn, ..._handle) {
|
|
@@ -115,7 +116,8 @@ function makeLoaderOrActionFactory() {
|
|
|
115
116
|
if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
|
|
116
117
|
return internalServerError();
|
|
117
118
|
}));
|
|
118
|
-
|
|
119
|
+
const provided = requestContextKey ? Effect.provideContext(program, args.context.get(requestContextKey)) : program;
|
|
120
|
+
return runtime ? runtime.runPromise(provided) : Effect.runPromise(provided);
|
|
119
121
|
};
|
|
120
122
|
}
|
|
121
123
|
return {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://schemastore.org/package.json",
|
|
3
3
|
"name": "react-router-effect",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"action",
|
package/src/factory.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, type ManagedRuntime } from "effect";
|
|
1
|
+
import { type Context, Effect, type ManagedRuntime } from "effect";
|
|
2
2
|
import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
|
|
3
3
|
import {
|
|
4
4
|
data,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type ActionFunctionArgs,
|
|
7
7
|
type UNSAFE_DataWithResponseInit as DataWithResponseInit,
|
|
8
8
|
type LoaderFunctionArgs,
|
|
9
|
+
type RouterContext,
|
|
9
10
|
} from "react-router";
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -52,22 +53,43 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
|
|
|
52
53
|
? never
|
|
53
54
|
: DataWithResponseInit<ReturnableBodyOf<E>>;
|
|
54
55
|
|
|
56
|
+
/** What a handler may return: a library route error or an `Effect` failing with a response. */
|
|
57
|
+
type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The inferred map of handler RETURN types, keyed by domain-error tag. Making the
|
|
61
|
+
* factory generic over *this* (rather than the whole handler map) is what lets
|
|
62
|
+
* `errorHandlers` contextually type each handler's `error` param from its key —
|
|
63
|
+
* no annotation needed — while still capturing precise return types for recovery.
|
|
64
|
+
*/
|
|
65
|
+
type HandlerReturns<DomainError extends Tagged> = Partial<
|
|
66
|
+
Record<DomainError["_tag"], HandlerReturn>
|
|
67
|
+
>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The `errorHandlers` field shape for a given return map: one entry per registered
|
|
71
|
+
* tag, whose `error` is the matching domain error (so it autocompletes and types
|
|
72
|
+
* itself) and whose return is the precise, inferred `Returns[Tag]`.
|
|
73
|
+
*/
|
|
74
|
+
type ErrorHandlers<DomainError extends Tagged, Returns> = {
|
|
75
|
+
[Tag in keyof Returns]: (error: Extract<DomainError, { readonly _tag: Tag }>) => Returns[Tag];
|
|
76
|
+
};
|
|
77
|
+
|
|
55
78
|
/**
|
|
56
|
-
* What the registered handlers recover into loader/action data —
|
|
57
|
-
*
|
|
79
|
+
* What the registered handlers recover into loader/action data — from each
|
|
80
|
+
* handler's return type, but only for handlers whose error can actually occur in
|
|
81
|
+
* `E`:
|
|
58
82
|
* - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
|
|
59
83
|
* - an `Effect.succeed(value)` recovers as `value`;
|
|
60
84
|
* - throwables, redirects and `Effect.fail(...)` contribute nothing.
|
|
61
85
|
*/
|
|
62
|
-
type RecoverOf<
|
|
63
|
-
[Tag in keyof
|
|
64
|
-
?
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
: never;
|
|
70
|
-
}[keyof Handlers];
|
|
86
|
+
type RecoverOf<DomainError extends Tagged, Returns, E> = {
|
|
87
|
+
[Tag in keyof Returns]: [Extract<E, Extract<DomainError, { readonly _tag: Tag }>>] extends [never]
|
|
88
|
+
? never
|
|
89
|
+
:
|
|
90
|
+
| (Returns[Tag] extends ReturnableDataError<infer B> ? DataWithResponseInit<B> : never)
|
|
91
|
+
| (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never);
|
|
92
|
+
}[keyof Returns];
|
|
71
93
|
|
|
72
94
|
/**
|
|
73
95
|
* Errors the library deals with on the loader's behalf — so the loader needn't
|
|
@@ -86,31 +108,6 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
|
|
|
86
108
|
*/
|
|
87
109
|
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
88
110
|
|
|
89
|
-
/**
|
|
90
|
-
* The shape a handler map must satisfy: an OPTIONAL handler per declared domain
|
|
91
|
-
* error, keyed by its tag, taking that error and returning a library route error
|
|
92
|
-
* or an `Effect` failing with a response. Used only as a validation constraint —
|
|
93
|
-
* the concrete handler types stay precise via the `const Handlers` inference.
|
|
94
|
-
*/
|
|
95
|
-
type ValidHandlers<DomainError extends Tagged> = {
|
|
96
|
-
[Tag in DomainError["_tag"]]?: (
|
|
97
|
-
error: Extract<DomainError, { readonly _tag: Tag }>,
|
|
98
|
-
) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* True when every registered handler is keyed by a declared domain error's tag and
|
|
103
|
-
* returns a library route error or a failing `Effect`. A handler for an unknown tag
|
|
104
|
-
* (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
|
|
105
|
-
*/
|
|
106
|
-
type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [
|
|
107
|
-
DomainError["_tag"],
|
|
108
|
-
]
|
|
109
|
-
? Handlers extends ValidHandlers<DomainError>
|
|
110
|
-
? true
|
|
111
|
-
: false
|
|
112
|
-
: false;
|
|
113
|
-
|
|
114
111
|
// ---------------------------------------------------------------------------
|
|
115
112
|
// Internal runtime helpers.
|
|
116
113
|
// ---------------------------------------------------------------------------
|
|
@@ -127,6 +124,39 @@ const processRouteError = (
|
|
|
127
124
|
return Effect.fail<FailureResponse>(redirect(e.url, e.init));
|
|
128
125
|
};
|
|
129
126
|
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Per-request context.
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A React Router context key holding a per-request effect `Context.Context`. Set
|
|
133
|
+
* it in middleware and pass it to the factory's `requestContext` — the runner
|
|
134
|
+
* reads it on every request and provides its services to the loader/action.
|
|
135
|
+
*
|
|
136
|
+
* It's just a `RouterContext`; create one with React Router's `createContext`:
|
|
137
|
+
*
|
|
138
|
+
* ```ts
|
|
139
|
+
* import { createContext } from "react-router";
|
|
140
|
+
* import { Context } from "effect";
|
|
141
|
+
* import type { RequestContextKey } from "react-router-effect";
|
|
142
|
+
*
|
|
143
|
+
* class RequestContext extends Context.Service<RequestContext, {
|
|
144
|
+
* readonly userId: string;
|
|
145
|
+
* }>()("app/RequestContext") {}
|
|
146
|
+
*
|
|
147
|
+
* export const requestContext: RequestContextKey<RequestContext> = createContext();
|
|
148
|
+
*
|
|
149
|
+
* // middleware:
|
|
150
|
+
* export const middleware: Route.MiddlewareFunction[] = [
|
|
151
|
+
* ({ context, request }, next) => {
|
|
152
|
+
* context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
|
|
153
|
+
* return next();
|
|
154
|
+
* },
|
|
155
|
+
* ];
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
|
|
159
|
+
|
|
130
160
|
// ---------------------------------------------------------------------------
|
|
131
161
|
// Factory.
|
|
132
162
|
// ---------------------------------------------------------------------------
|
|
@@ -135,18 +165,18 @@ const processRouteError = (
|
|
|
135
165
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
136
166
|
*
|
|
137
167
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
138
|
-
* *optional* handler per domain error in `errorHandlers
|
|
139
|
-
*
|
|
140
|
-
*
|
|
168
|
+
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
169
|
+
* autocomplete to your domain-error tags and each `error` parameter is typed from
|
|
170
|
+
* its key — no annotation needed. It's curried so you can pin the domain errors
|
|
171
|
+
* while the handler return types are still inferred:
|
|
141
172
|
*
|
|
142
173
|
* ```ts
|
|
143
174
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
144
175
|
*
|
|
145
176
|
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
146
177
|
* errorHandlers: {
|
|
147
|
-
* // throw → error boundary
|
|
148
|
-
* MyDomainError: (error:
|
|
149
|
-
* Effect.fail(new Response(error.message, { status: 400 })),
|
|
178
|
+
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
179
|
+
* MyDomainError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
|
|
150
180
|
* // DbError has no handler → falls through to the 500 default.
|
|
151
181
|
* },
|
|
152
182
|
* });
|
|
@@ -183,32 +213,37 @@ const processRouteError = (
|
|
|
183
213
|
* ```
|
|
184
214
|
*/
|
|
185
215
|
export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
|
|
186
|
-
return function defineErrorHandlers<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
return function defineErrorHandlers<
|
|
217
|
+
const Returns extends HandlerReturns<DomainError> = {},
|
|
218
|
+
RServices = never,
|
|
219
|
+
ReqServices = never,
|
|
220
|
+
>(config: {
|
|
221
|
+
/**
|
|
222
|
+
* An optional handler per declared domain error, keyed by its tag. The keys
|
|
223
|
+
* autocomplete to your domain-error tags and each handler's `error` parameter
|
|
224
|
+
* is typed automatically — no annotation needed. A handler *remaps* the error
|
|
225
|
+
* by returning a library route error (`Respond.early` / `throw` / `redirect`)
|
|
226
|
+
* or an `Effect` (`Effect.succeed`/`fail`). Omit entirely to register none.
|
|
227
|
+
*/
|
|
228
|
+
errorHandlers?: ErrorHandlers<DomainError, Returns>;
|
|
229
|
+
/**
|
|
230
|
+
* The app runtime that provides services to loader/action effects. When
|
|
231
|
+
* set, effects may require its services (`RServices`, inferred from here)
|
|
232
|
+
* without providing layers, and runs go through `runtime.runPromise`. When
|
|
233
|
+
* omitted, `RServices` is `never` and effects must require nothing.
|
|
234
|
+
*/
|
|
235
|
+
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
236
|
+
/**
|
|
237
|
+
* A React Router context key (a {@link RequestContextKey}) holding a
|
|
238
|
+
* per-request effect `Context.Context`. Middleware sets it for each request;
|
|
239
|
+
* the runner reads `args.context.get(requestContext)` and provides those
|
|
240
|
+
* services to the effect. Loader/action effects may then require
|
|
241
|
+
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
242
|
+
*/
|
|
243
|
+
requestContext?: RequestContextKey<ReqServices>;
|
|
244
|
+
}) {
|
|
211
245
|
const runtime = config.runtime;
|
|
246
|
+
const requestContextKey = config.requestContext;
|
|
212
247
|
|
|
213
248
|
// Uniform call signature for dispatch (the per-tag handler types are narrower).
|
|
214
249
|
// Defaults to an empty map when `errorHandlers` is omitted.
|
|
@@ -221,9 +256,11 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
221
256
|
typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
|
|
222
257
|
|
|
223
258
|
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
|
|
224
|
-
// The effect may require `RServices`
|
|
225
|
-
//
|
|
226
|
-
|
|
259
|
+
// The effect may require `RServices` (provided by the configured `runtime`)
|
|
260
|
+
// and `ReqServices` (provided per-request from `requestContext`). Both are
|
|
261
|
+
// `never` when their source isn't configured, so the effect must require
|
|
262
|
+
// nothing extra.
|
|
263
|
+
fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>,
|
|
227
264
|
// If the effect can still fail with something the library won't handle — a
|
|
228
265
|
// service-specific error that isn't a declared domain error, a library route
|
|
229
266
|
// error, or respondable — this rest parameter becomes required and the call
|
|
@@ -236,7 +273,7 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
236
273
|
DomainError
|
|
237
274
|
>,
|
|
238
275
|
]
|
|
239
|
-
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<
|
|
276
|
+
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>> {
|
|
240
277
|
return (args: Args) => {
|
|
241
278
|
// The internal channel is deliberately loose (`unknown` success); the outer
|
|
242
279
|
// cast restores the precise resolved type (computed from `E` and the handler
|
|
@@ -269,12 +306,21 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
269
306
|
},
|
|
270
307
|
),
|
|
271
308
|
);
|
|
309
|
+
// Provide the per-request context (set by middleware) so `ReqServices` are
|
|
310
|
+
// satisfied, leaving only the runtime's `RServices` in the requirements.
|
|
311
|
+
// The cast is sound: `provideContext` removes `ReqServices`, and when no
|
|
312
|
+
// `requestContext` is configured `ReqServices` is `never` (nothing removed).
|
|
313
|
+
const provided = (
|
|
314
|
+
requestContextKey
|
|
315
|
+
? Effect.provideContext(program, args.context.get(requestContextKey))
|
|
316
|
+
: program
|
|
317
|
+
) as Effect.Effect<unknown, FailureResponse, RServices>;
|
|
272
318
|
// Run against the configured runtime so its services satisfy the effect's
|
|
273
319
|
// `R`; with no runtime, the effect requires nothing and runs standalone.
|
|
274
320
|
const result = runtime
|
|
275
|
-
? runtime.runPromise(
|
|
276
|
-
: Effect.runPromise(
|
|
277
|
-
return result as Promise<A | DirectRecover<E> | RecoverOf<
|
|
321
|
+
? runtime.runPromise(provided)
|
|
322
|
+
: Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>);
|
|
323
|
+
return result as Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
|
|
278
324
|
};
|
|
279
325
|
}
|
|
280
326
|
|
package/src/index.ts
CHANGED