react-router-effect 0.4.0 → 0.6.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 +38 -30
- package/dist/index.d.mts +79 -48
- package/dist/index.mjs +38 -23
- package/package.json +1 -1
- package/src/errors.ts +10 -7
- package/src/factory.ts +89 -66
- package/src/index.ts +0 -1
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ handling of feature/service-specific ones.
|
|
|
64
64
|
```ts
|
|
65
65
|
// app/route.server.ts
|
|
66
66
|
import { Data, Effect } from "effect";
|
|
67
|
-
import { makeLoaderOrActionFactory
|
|
67
|
+
import { makeLoaderOrActionFactory } from "react-router-effect";
|
|
68
68
|
|
|
69
69
|
class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
|
|
70
70
|
class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
|
|
@@ -74,26 +74,31 @@ class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
|
|
|
74
74
|
// so it falls through to the 500 default.
|
|
75
75
|
type DomainErrors = FormError | BadInputError | DbError;
|
|
76
76
|
|
|
77
|
-
// Curried: pin the domain errors, then the handler types are inferred.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
77
|
+
// Curried: pin the domain errors, then the handler types are inferred. The config
|
|
78
|
+
// is a *builder* — it receives `Respond` (the library's `early`/`throw`/`redirect`)
|
|
79
|
+
// so your handlers can recover/throw, and the factory hands back a single `Respond`
|
|
80
|
+
// for your routes to import (no auto-import ambiguity with a library export).
|
|
81
|
+
export const { makeLoader, makeAction, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
|
|
82
|
+
(Respond) => ({
|
|
83
|
+
// App-specific helpers — merged onto the returned `Respond` (base helpers win):
|
|
84
|
+
respond: {
|
|
85
|
+
formError: (reply: SubmissionResponse) => new FormError({ reply }),
|
|
86
|
+
},
|
|
87
|
+
errorHandlers: {
|
|
88
|
+
// keys autocomplete to your domain-error tags; `error` is typed from its key.
|
|
89
|
+
// recover: short-circuit and hand the reply to the component
|
|
90
|
+
FormError: (error) => Respond.early({ reply: error.reply }),
|
|
91
|
+
// throw: send to the error boundary
|
|
92
|
+
BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
93
96
|
```
|
|
94
97
|
|
|
95
|
-
> **
|
|
96
|
-
>
|
|
98
|
+
> **One `Respond`, no annotations.** Your routes import the `Respond` returned here — there's
|
|
99
|
+
> no library-level `Respond` to clash with it on auto-import. Handler keys autocomplete to your
|
|
100
|
+
> declared domain-error tags, each `error` parameter is typed from its key, and the precise
|
|
101
|
+
> recover types are derived from each handler's return.
|
|
97
102
|
|
|
98
103
|
### 2. Write loaders/actions as effects
|
|
99
104
|
|
|
@@ -152,10 +157,10 @@ import { ManagedRuntime } from "effect";
|
|
|
152
157
|
export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
|
|
153
158
|
|
|
154
159
|
// app/route.server.ts
|
|
155
|
-
export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
160
|
+
export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
156
161
|
runtime: appRuntime,
|
|
157
162
|
errorHandlers: { ... },
|
|
158
|
-
});
|
|
163
|
+
}));
|
|
159
164
|
|
|
160
165
|
// app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
|
|
161
166
|
const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
@@ -173,8 +178,8 @@ runtime provides type-checks, while requiring one it _doesn't_ is a compile erro
|
|
|
173
178
|
`errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
|
|
174
179
|
|
|
175
180
|
```ts
|
|
176
|
-
makeLoaderOrActionFactory()({ runtime });
|
|
177
|
-
makeLoaderOrActionFactory()({});
|
|
181
|
+
makeLoaderOrActionFactory()(() => ({ runtime }));
|
|
182
|
+
makeLoaderOrActionFactory()(() => ({}));
|
|
178
183
|
```
|
|
179
184
|
|
|
180
185
|
### Per-request services from middleware
|
|
@@ -209,10 +214,10 @@ export const middleware: Route.MiddlewareFunction[] = [
|
|
|
209
214
|
];
|
|
210
215
|
|
|
211
216
|
// app/route.server.ts — wire the same key into the factory:
|
|
212
|
-
export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
217
|
+
export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
213
218
|
runtime: appRuntime,
|
|
214
219
|
requestContext,
|
|
215
|
-
});
|
|
220
|
+
}));
|
|
216
221
|
|
|
217
222
|
// the loader requires RequestContext directly — no provide, fresh each request:
|
|
218
223
|
export const loader = makeLoader(() =>
|
|
@@ -229,13 +234,16 @@ else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
|
|
|
229
234
|
|
|
230
235
|
## API
|
|
231
236
|
|
|
232
|
-
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime?, requestContext? })`**
|
|
233
|
-
`{ makeLoader, makeAction }` (
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
236
243
|
- **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
|
|
237
244
|
effect context (`RouterContext<Context.Context<ReqServices>>`).
|
|
238
|
-
- **`Respond`** — `early` (recover), `throw`, `redirect
|
|
245
|
+
- **`Respond`** (returned from the factory) — `early` (recover), `throw`, `redirect`, plus any
|
|
246
|
+
`respond` helpers you add.
|
|
239
247
|
- **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
|
|
240
248
|
route errors, and **`isRouteError`** to narrow them.
|
|
241
249
|
- **`ErrorHandler<Err>`** — the handler signature type.
|
package/dist/index.d.mts
CHANGED
|
@@ -30,15 +30,18 @@ declare class ThrowableRedirectError extends ThrowableRedirectError_base<{
|
|
|
30
30
|
/** Every library route error, as a single union. */
|
|
31
31
|
type AnyRouteError = ReturnableDataError<unknown> | ThrowableDataError<unknown> | ThrowableRedirectError;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* The library's base helpers for raising route errors (`early` / `throw` /
|
|
34
|
+
* `redirect`). This is internal: it isn't exported from the package. The factory
|
|
35
|
+
* hands it to your config builder and returns it (merged with any `respond`
|
|
36
|
+
* extensions) so your app imports a single `Respond`:
|
|
36
37
|
*
|
|
37
38
|
* ```ts
|
|
38
|
-
* export const Respond =
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* }
|
|
39
|
+
* export const { makeLoader, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
|
|
40
|
+
* (Respond) => ({
|
|
41
|
+
* respond: { formError: (reply) => new FormError({ reply }) },
|
|
42
|
+
* errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
|
|
43
|
+
* }),
|
|
44
|
+
* );
|
|
42
45
|
* ```
|
|
43
46
|
*/
|
|
44
47
|
declare const Respond: {
|
|
@@ -50,6 +53,8 @@ declare const Respond: {
|
|
|
50
53
|
declare const isRouteError: (value: unknown) => value is AnyRouteError;
|
|
51
54
|
//#endregion
|
|
52
55
|
//#region src/factory.d.ts
|
|
56
|
+
/** The library's base `Respond` helpers (`early` / `throw` / `redirect`). */
|
|
57
|
+
type BaseRespond = typeof Respond;
|
|
53
58
|
type Tagged = {
|
|
54
59
|
readonly _tag: string;
|
|
55
60
|
};
|
|
@@ -76,14 +81,34 @@ type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : ne
|
|
|
76
81
|
* `DataWithResponseInit<never>` into its return type.
|
|
77
82
|
*/
|
|
78
83
|
type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never] ? never : UNSAFE_DataWithResponseInit<ReturnableBodyOf<E>>;
|
|
84
|
+
/** What a handler may return: a library route error or an `Effect` failing with a response. */
|
|
85
|
+
type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
79
86
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
87
|
+
* The inferred map of handler RETURN types, keyed by domain-error tag. Making the
|
|
88
|
+
* factory generic over *this* (rather than the whole handler map) is what lets
|
|
89
|
+
* `errorHandlers` contextually type each handler's `error` param from its key —
|
|
90
|
+
* no annotation needed — while still capturing precise return types for recovery.
|
|
91
|
+
*/
|
|
92
|
+
type HandlerReturns<DomainError extends Tagged> = Partial<Record<DomainError["_tag"], HandlerReturn>>;
|
|
93
|
+
/**
|
|
94
|
+
* The `errorHandlers` field shape for a given return map: one entry per registered
|
|
95
|
+
* tag, whose `error` is the matching domain error (so it autocompletes and types
|
|
96
|
+
* itself) and whose return is the precise, inferred `Returns[Tag]`.
|
|
97
|
+
*/
|
|
98
|
+
type ErrorHandlers<DomainError extends Tagged, Returns> = { [Tag in keyof Returns]: (error: Extract<DomainError, {
|
|
99
|
+
readonly _tag: Tag;
|
|
100
|
+
}>) => Returns[Tag] };
|
|
101
|
+
/**
|
|
102
|
+
* What the registered handlers recover into loader/action data — from each
|
|
103
|
+
* handler's return type, but only for handlers whose error can actually occur in
|
|
104
|
+
* `E`:
|
|
82
105
|
* - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
|
|
83
106
|
* - an `Effect.succeed(value)` recovers as `value`;
|
|
84
107
|
* - throwables, redirects and `Effect.fail(...)` contribute nothing.
|
|
85
108
|
*/
|
|
86
|
-
type RecoverOf<
|
|
109
|
+
type RecoverOf<DomainError extends Tagged, Returns, E> = { [Tag in keyof Returns]: [Extract<E, Extract<DomainError, {
|
|
110
|
+
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];
|
|
87
112
|
/**
|
|
88
113
|
* Errors the library deals with on the loader's behalf — so the loader needn't
|
|
89
114
|
* handle them itself:
|
|
@@ -99,21 +124,6 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
|
|
|
99
124
|
* errors. The loader/action must handle these itself (catch or map them).
|
|
100
125
|
*/
|
|
101
126
|
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
102
|
-
/**
|
|
103
|
-
* The shape a handler map must satisfy: an OPTIONAL handler per declared domain
|
|
104
|
-
* error, keyed by its tag, taking that error and returning a library route error
|
|
105
|
-
* or an `Effect` failing with a response. Used only as a validation constraint —
|
|
106
|
-
* the concrete handler types stay precise via the `const Handlers` inference.
|
|
107
|
-
*/
|
|
108
|
-
type ValidHandlers<DomainError extends Tagged> = { [Tag in DomainError["_tag"]]?: (error: Extract<DomainError, {
|
|
109
|
-
readonly _tag: Tag;
|
|
110
|
-
}>) => AnyRouteError | Effect.Effect<unknown, FailureResponse> };
|
|
111
|
-
/**
|
|
112
|
-
* True when every registered handler is keyed by a declared domain error's tag and
|
|
113
|
-
* returns a library route error or a failing `Effect`. A handler for an unknown tag
|
|
114
|
-
* (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
|
|
115
|
-
*/
|
|
116
|
-
type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [DomainError["_tag"]] ? Handlers extends ValidHandlers<DomainError> ? true : false : false;
|
|
117
127
|
/**
|
|
118
128
|
* A React Router context key holding a per-request effect `Context.Context`. Set
|
|
119
129
|
* it in middleware and pass it to the factory's `requestContext` — the runner
|
|
@@ -146,23 +156,29 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
146
156
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
147
157
|
*
|
|
148
158
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
149
|
-
* *optional* handler per domain error in `errorHandlers
|
|
150
|
-
*
|
|
151
|
-
*
|
|
159
|
+
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
160
|
+
* autocomplete to your domain-error tags and each `error` parameter is typed from
|
|
161
|
+
* its key — no annotation needed. It's curried so you can pin the domain errors
|
|
162
|
+
* while the handler return types are still inferred:
|
|
152
163
|
*
|
|
153
164
|
* ```ts
|
|
154
165
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
155
166
|
*
|
|
156
|
-
* const { makeLoader, makeAction } =
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
167
|
+
* export const { makeLoader, makeAction, Respond } =
|
|
168
|
+
* makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
|
|
169
|
+
* errorHandlers: {
|
|
170
|
+
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
171
|
+
* MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
|
|
172
|
+
* // DbError has no handler → falls through to the 500 default.
|
|
173
|
+
* },
|
|
174
|
+
* }));
|
|
164
175
|
* ```
|
|
165
176
|
*
|
|
177
|
+
* The inner config is a **builder** — it receives the library's `Respond` helpers so
|
|
178
|
+
* your handlers can recover/throw with them, and returns the config. `Respond` is
|
|
179
|
+
* also returned from the factory (extended with any `respond` helpers you add), so
|
|
180
|
+
* your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
|
|
181
|
+
*
|
|
166
182
|
* A handler *remaps* the error by returning either a library route error
|
|
167
183
|
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
168
184
|
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
@@ -179,10 +195,10 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
179
195
|
* services directly — no per-call `Effect.provide`:
|
|
180
196
|
*
|
|
181
197
|
* ```ts
|
|
182
|
-
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
198
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
183
199
|
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
184
200
|
* errorHandlers: { ... },
|
|
185
|
-
* });
|
|
201
|
+
* }));
|
|
186
202
|
*
|
|
187
203
|
* // `MyService` is satisfied by the runtime, not provided here:
|
|
188
204
|
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
@@ -193,13 +209,15 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
193
209
|
* );
|
|
194
210
|
* ```
|
|
195
211
|
*/
|
|
196
|
-
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const
|
|
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) => {
|
|
197
213
|
/**
|
|
198
|
-
* An optional handler per declared domain error
|
|
199
|
-
*
|
|
200
|
-
*
|
|
214
|
+
* An optional handler per declared domain error, keyed by its tag. The keys
|
|
215
|
+
* autocomplete to your domain-error tags and each handler's `error` parameter
|
|
216
|
+
* is typed automatically — no annotation needed. A handler *remaps* the error
|
|
217
|
+
* by returning a library route error (the builder's `Respond.early` / `throw`
|
|
218
|
+
* / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
|
|
201
219
|
*/
|
|
202
|
-
errorHandlers?:
|
|
220
|
+
errorHandlers?: ErrorHandlers<DomainError, Returns>;
|
|
203
221
|
/**
|
|
204
222
|
* The app runtime that provides services to loader/action effects. When
|
|
205
223
|
* set, effects may require its services (`RServices`, inferred from here)
|
|
@@ -208,16 +226,29 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
208
226
|
*/
|
|
209
227
|
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
210
228
|
/**
|
|
211
|
-
* A React Router context key (
|
|
229
|
+
* A React Router context key (a {@link RequestContextKey}) holding a
|
|
212
230
|
* per-request effect `Context.Context`. Middleware sets it for each request;
|
|
213
231
|
* the runner reads `args.context.get(requestContext)` and provides those
|
|
214
232
|
* services to the effect. Loader/action effects may then require
|
|
215
233
|
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
216
234
|
*/
|
|
217
235
|
requestContext?: RequestContextKey<ReqServices>;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
236
|
+
/**
|
|
237
|
+
* App-specific `Respond` helpers — merged onto the base `Respond` that the
|
|
238
|
+
* factory returns, so your app imports one `Respond`. Typically opinionated
|
|
239
|
+
* error constructors; annotate their parameters, e.g.
|
|
240
|
+
* `{ formError: (reply: string) => new FormError({ reply }) }`. The base
|
|
241
|
+
* helpers (`early` / `throw` / `redirect`) always win the merge.
|
|
242
|
+
*/
|
|
243
|
+
respond?: ExtraRespond;
|
|
244
|
+
}) => {
|
|
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>>;
|
|
247
|
+
Respond: {
|
|
248
|
+
early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
|
|
249
|
+
throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
|
|
250
|
+
redirect: (url: string, init?: number | ResponseInit) => ThrowableRedirectError;
|
|
251
|
+
} & ExtraRespond;
|
|
221
252
|
};
|
|
222
253
|
//#endregion
|
|
223
|
-
export { type AnyRouteError, type ErrorHandler, type RequestContextKey,
|
|
254
|
+
export { type AnyRouteError, type ErrorHandler, type RequestContextKey, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
package/dist/index.mjs
CHANGED
|
@@ -9,15 +9,18 @@ var ThrowableDataError = class extends Data.TaggedError("ThrowableRouteError") {
|
|
|
9
9
|
/** Throwable: rejects with a redirect `Response`. */
|
|
10
10
|
var ThrowableRedirectError = class extends Data.TaggedError("ThrowableRedirectError") {};
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The library's base helpers for raising route errors (`early` / `throw` /
|
|
13
|
+
* `redirect`). This is internal: it isn't exported from the package. The factory
|
|
14
|
+
* hands it to your config builder and returns it (merged with any `respond`
|
|
15
|
+
* extensions) so your app imports a single `Respond`:
|
|
15
16
|
*
|
|
16
17
|
* ```ts
|
|
17
|
-
* export const Respond =
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* }
|
|
18
|
+
* export const { makeLoader, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
|
|
19
|
+
* (Respond) => ({
|
|
20
|
+
* respond: { formError: (reply) => new FormError({ reply }) },
|
|
21
|
+
* errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
|
|
22
|
+
* }),
|
|
23
|
+
* );
|
|
21
24
|
* ```
|
|
22
25
|
*/
|
|
23
26
|
const Respond = {
|
|
@@ -52,23 +55,29 @@ const processRouteError = (e) => {
|
|
|
52
55
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
53
56
|
*
|
|
54
57
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
55
|
-
* *optional* handler per domain error in `errorHandlers
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
+
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
59
|
+
* autocomplete to your domain-error tags and each `error` parameter is typed from
|
|
60
|
+
* its key — no annotation needed. It's curried so you can pin the domain errors
|
|
61
|
+
* while the handler return types are still inferred:
|
|
58
62
|
*
|
|
59
63
|
* ```ts
|
|
60
64
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
61
65
|
*
|
|
62
|
-
* const { makeLoader, makeAction } =
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
66
|
+
* export const { makeLoader, makeAction, Respond } =
|
|
67
|
+
* makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
|
|
68
|
+
* errorHandlers: {
|
|
69
|
+
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
70
|
+
* MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
|
|
71
|
+
* // DbError has no handler → falls through to the 500 default.
|
|
72
|
+
* },
|
|
73
|
+
* }));
|
|
70
74
|
* ```
|
|
71
75
|
*
|
|
76
|
+
* The inner config is a **builder** — it receives the library's `Respond` helpers so
|
|
77
|
+
* your handlers can recover/throw with them, and returns the config. `Respond` is
|
|
78
|
+
* also returned from the factory (extended with any `respond` helpers you add), so
|
|
79
|
+
* your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
|
|
80
|
+
*
|
|
72
81
|
* A handler *remaps* the error by returning either a library route error
|
|
73
82
|
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
74
83
|
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
@@ -85,10 +94,10 @@ const processRouteError = (e) => {
|
|
|
85
94
|
* services directly — no per-call `Effect.provide`:
|
|
86
95
|
*
|
|
87
96
|
* ```ts
|
|
88
|
-
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
97
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
89
98
|
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
90
99
|
* errorHandlers: { ... },
|
|
91
|
-
* });
|
|
100
|
+
* }));
|
|
92
101
|
*
|
|
93
102
|
* // `MyService` is satisfied by the runtime, not provided here:
|
|
94
103
|
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
@@ -100,9 +109,14 @@ const processRouteError = (e) => {
|
|
|
100
109
|
* ```
|
|
101
110
|
*/
|
|
102
111
|
function makeLoaderOrActionFactory() {
|
|
103
|
-
return function defineErrorHandlers(
|
|
112
|
+
return function defineErrorHandlers(builder) {
|
|
113
|
+
const config = builder(Respond);
|
|
104
114
|
const runtime = config.runtime;
|
|
105
115
|
const requestContextKey = config.requestContext;
|
|
116
|
+
const Respond$1 = {
|
|
117
|
+
...config.respond,
|
|
118
|
+
...Respond
|
|
119
|
+
};
|
|
106
120
|
const userHandlers = config.errorHandlers ?? {};
|
|
107
121
|
const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
|
|
108
122
|
function makeLoaderOrAction(fn, ..._handle) {
|
|
@@ -122,9 +136,10 @@ function makeLoaderOrActionFactory() {
|
|
|
122
136
|
}
|
|
123
137
|
return {
|
|
124
138
|
makeLoader: makeLoaderOrAction,
|
|
125
|
-
makeAction: makeLoaderOrAction
|
|
139
|
+
makeAction: makeLoaderOrAction,
|
|
140
|
+
Respond: Respond$1
|
|
126
141
|
};
|
|
127
142
|
};
|
|
128
143
|
}
|
|
129
144
|
//#endregion
|
|
130
|
-
export {
|
|
145
|
+
export { ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
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.6.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/errors.ts
CHANGED
|
@@ -29,15 +29,18 @@ export type AnyRouteError =
|
|
|
29
29
|
| ThrowableRedirectError;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* The library's base helpers for raising route errors (`early` / `throw` /
|
|
33
|
+
* `redirect`). This is internal: it isn't exported from the package. The factory
|
|
34
|
+
* hands it to your config builder and returns it (merged with any `respond`
|
|
35
|
+
* extensions) so your app imports a single `Respond`:
|
|
35
36
|
*
|
|
36
37
|
* ```ts
|
|
37
|
-
* export const Respond =
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* }
|
|
38
|
+
* export const { makeLoader, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
|
|
39
|
+
* (Respond) => ({
|
|
40
|
+
* respond: { formError: (reply) => new FormError({ reply }) },
|
|
41
|
+
* errorHandlers: { FormError: (e) => Respond.early({ reply: e.reply }) },
|
|
42
|
+
* }),
|
|
43
|
+
* );
|
|
41
44
|
* ```
|
|
42
45
|
*/
|
|
43
46
|
export const Respond = {
|
package/src/factory.ts
CHANGED
|
@@ -11,11 +11,15 @@ import {
|
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
isRouteError,
|
|
14
|
+
Respond as baseRespond,
|
|
14
15
|
ReturnableDataError,
|
|
15
16
|
ThrowableDataError,
|
|
16
17
|
type AnyRouteError,
|
|
17
18
|
} from "./errors.ts";
|
|
18
19
|
|
|
20
|
+
/** The library's base `Respond` helpers (`early` / `throw` / `redirect`). */
|
|
21
|
+
type BaseRespond = typeof baseRespond;
|
|
22
|
+
|
|
19
23
|
// ---------------------------------------------------------------------------
|
|
20
24
|
// Type-level plumbing.
|
|
21
25
|
// ---------------------------------------------------------------------------
|
|
@@ -53,22 +57,43 @@ type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
|
|
|
53
57
|
? never
|
|
54
58
|
: DataWithResponseInit<ReturnableBodyOf<E>>;
|
|
55
59
|
|
|
60
|
+
/** What a handler may return: a library route error or an `Effect` failing with a response. */
|
|
61
|
+
type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The inferred map of handler RETURN types, keyed by domain-error tag. Making the
|
|
65
|
+
* factory generic over *this* (rather than the whole handler map) is what lets
|
|
66
|
+
* `errorHandlers` contextually type each handler's `error` param from its key —
|
|
67
|
+
* no annotation needed — while still capturing precise return types for recovery.
|
|
68
|
+
*/
|
|
69
|
+
type HandlerReturns<DomainError extends Tagged> = Partial<
|
|
70
|
+
Record<DomainError["_tag"], HandlerReturn>
|
|
71
|
+
>;
|
|
72
|
+
|
|
56
73
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
74
|
+
* The `errorHandlers` field shape for a given return map: one entry per registered
|
|
75
|
+
* tag, whose `error` is the matching domain error (so it autocompletes and types
|
|
76
|
+
* itself) and whose return is the precise, inferred `Returns[Tag]`.
|
|
77
|
+
*/
|
|
78
|
+
type ErrorHandlers<DomainError extends Tagged, Returns> = {
|
|
79
|
+
[Tag in keyof Returns]: (error: Extract<DomainError, { readonly _tag: Tag }>) => Returns[Tag];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What the registered handlers recover into loader/action data — from each
|
|
84
|
+
* handler's return type, but only for handlers whose error can actually occur in
|
|
85
|
+
* `E`:
|
|
59
86
|
* - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
|
|
60
87
|
* - an `Effect.succeed(value)` recovers as `value`;
|
|
61
88
|
* - throwables, redirects and `Effect.fail(...)` contribute nothing.
|
|
62
89
|
*/
|
|
63
|
-
type RecoverOf<
|
|
64
|
-
[Tag in keyof
|
|
65
|
-
?
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
: never;
|
|
71
|
-
}[keyof Handlers];
|
|
90
|
+
type RecoverOf<DomainError extends Tagged, Returns, E> = {
|
|
91
|
+
[Tag in keyof Returns]: [Extract<E, Extract<DomainError, { readonly _tag: Tag }>>] extends [never]
|
|
92
|
+
? never
|
|
93
|
+
:
|
|
94
|
+
| (Returns[Tag] extends ReturnableDataError<infer B> ? DataWithResponseInit<B> : never)
|
|
95
|
+
| (Returns[Tag] extends Effect.Effect<infer SuccessValue, any, any> ? SuccessValue : never);
|
|
96
|
+
}[keyof Returns];
|
|
72
97
|
|
|
73
98
|
/**
|
|
74
99
|
* Errors the library deals with on the loader's behalf — so the loader needn't
|
|
@@ -87,31 +112,6 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
|
|
|
87
112
|
*/
|
|
88
113
|
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
89
114
|
|
|
90
|
-
/**
|
|
91
|
-
* The shape a handler map must satisfy: an OPTIONAL handler per declared domain
|
|
92
|
-
* error, keyed by its tag, taking that error and returning a library route error
|
|
93
|
-
* or an `Effect` failing with a response. Used only as a validation constraint —
|
|
94
|
-
* the concrete handler types stay precise via the `const Handlers` inference.
|
|
95
|
-
*/
|
|
96
|
-
type ValidHandlers<DomainError extends Tagged> = {
|
|
97
|
-
[Tag in DomainError["_tag"]]?: (
|
|
98
|
-
error: Extract<DomainError, { readonly _tag: Tag }>,
|
|
99
|
-
) => AnyRouteError | Effect.Effect<unknown, FailureResponse>;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* True when every registered handler is keyed by a declared domain error's tag and
|
|
104
|
-
* returns a library route error or a failing `Effect`. A handler for an unknown tag
|
|
105
|
-
* (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
|
|
106
|
-
*/
|
|
107
|
-
type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [
|
|
108
|
-
DomainError["_tag"],
|
|
109
|
-
]
|
|
110
|
-
? Handlers extends ValidHandlers<DomainError>
|
|
111
|
-
? true
|
|
112
|
-
: false
|
|
113
|
-
: false;
|
|
114
|
-
|
|
115
115
|
// ---------------------------------------------------------------------------
|
|
116
116
|
// Internal runtime helpers.
|
|
117
117
|
// ---------------------------------------------------------------------------
|
|
@@ -169,23 +169,29 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
169
169
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
170
170
|
*
|
|
171
171
|
* Declare the app's **domain errors** as the type argument, then register an
|
|
172
|
-
* *optional* handler per domain error in `errorHandlers
|
|
173
|
-
*
|
|
174
|
-
*
|
|
172
|
+
* *optional* handler per domain error in `errorHandlers`. The handler keys
|
|
173
|
+
* autocomplete to your domain-error tags and each `error` parameter is typed from
|
|
174
|
+
* its key — no annotation needed. It's curried so you can pin the domain errors
|
|
175
|
+
* while the handler return types are still inferred:
|
|
175
176
|
*
|
|
176
177
|
* ```ts
|
|
177
178
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
178
179
|
*
|
|
179
|
-
* const { makeLoader, makeAction } =
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
180
|
+
* export const { makeLoader, makeAction, Respond } =
|
|
181
|
+
* makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
|
|
182
|
+
* errorHandlers: {
|
|
183
|
+
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
184
|
+
* MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
|
|
185
|
+
* // DbError has no handler → falls through to the 500 default.
|
|
186
|
+
* },
|
|
187
|
+
* }));
|
|
187
188
|
* ```
|
|
188
189
|
*
|
|
190
|
+
* The inner config is a **builder** — it receives the library's `Respond` helpers so
|
|
191
|
+
* your handlers can recover/throw with them, and returns the config. `Respond` is
|
|
192
|
+
* also returned from the factory (extended with any `respond` helpers you add), so
|
|
193
|
+
* your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
|
|
194
|
+
*
|
|
189
195
|
* A handler *remaps* the error by returning either a library route error
|
|
190
196
|
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
191
197
|
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
@@ -202,10 +208,10 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
202
208
|
* services directly — no per-call `Effect.provide`:
|
|
203
209
|
*
|
|
204
210
|
* ```ts
|
|
205
|
-
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
211
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
206
212
|
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
207
213
|
* errorHandlers: { ... },
|
|
208
|
-
* });
|
|
214
|
+
* }));
|
|
209
215
|
*
|
|
210
216
|
* // `MyService` is satisfied by the runtime, not provided here:
|
|
211
217
|
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
@@ -217,14 +223,26 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
217
223
|
* ```
|
|
218
224
|
*/
|
|
219
225
|
export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
|
|
220
|
-
return function defineErrorHandlers<
|
|
221
|
-
|
|
226
|
+
return function defineErrorHandlers<
|
|
227
|
+
const Returns extends HandlerReturns<DomainError> = {},
|
|
228
|
+
RServices = never,
|
|
229
|
+
ReqServices = never,
|
|
230
|
+
const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {},
|
|
231
|
+
>(
|
|
232
|
+
/**
|
|
233
|
+
* Builds the factory config. It receives the library's base `Respond` helpers
|
|
234
|
+
* (`early` / `throw` / `redirect`) so your `errorHandlers` can recover/throw
|
|
235
|
+
* with them, and returns the config object.
|
|
236
|
+
*/
|
|
237
|
+
builder: (respond: BaseRespond) => {
|
|
222
238
|
/**
|
|
223
|
-
* An optional handler per declared domain error
|
|
224
|
-
*
|
|
225
|
-
*
|
|
239
|
+
* An optional handler per declared domain error, keyed by its tag. The keys
|
|
240
|
+
* autocomplete to your domain-error tags and each handler's `error` parameter
|
|
241
|
+
* is typed automatically — no annotation needed. A handler *remaps* the error
|
|
242
|
+
* by returning a library route error (the builder's `Respond.early` / `throw`
|
|
243
|
+
* / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
|
|
226
244
|
*/
|
|
227
|
-
errorHandlers?:
|
|
245
|
+
errorHandlers?: ErrorHandlers<DomainError, Returns>;
|
|
228
246
|
/**
|
|
229
247
|
* The app runtime that provides services to loader/action effects. When
|
|
230
248
|
* set, effects may require its services (`RServices`, inferred from here)
|
|
@@ -233,25 +251,29 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
233
251
|
*/
|
|
234
252
|
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
235
253
|
/**
|
|
236
|
-
* A React Router context key (
|
|
254
|
+
* A React Router context key (a {@link RequestContextKey}) holding a
|
|
237
255
|
* per-request effect `Context.Context`. Middleware sets it for each request;
|
|
238
256
|
* the runner reads `args.context.get(requestContext)` and provides those
|
|
239
257
|
* services to the effect. Loader/action effects may then require
|
|
240
258
|
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
241
259
|
*/
|
|
242
260
|
requestContext?: RequestContextKey<ReqServices>;
|
|
261
|
+
/**
|
|
262
|
+
* App-specific `Respond` helpers — merged onto the base `Respond` that the
|
|
263
|
+
* factory returns, so your app imports one `Respond`. Typically opinionated
|
|
264
|
+
* error constructors; annotate their parameters, e.g.
|
|
265
|
+
* `{ formError: (reply: string) => new FormError({ reply }) }`. The base
|
|
266
|
+
* helpers (`early` / `throw` / `redirect`) always win the merge.
|
|
267
|
+
*/
|
|
268
|
+
respond?: ExtraRespond;
|
|
243
269
|
},
|
|
244
|
-
// Validation: a handler for a non-domain error, or one that doesn't return a
|
|
245
|
-
// route error / failing `Effect`, makes this rest parameter required and forces
|
|
246
|
-
// a compile error at the call.
|
|
247
|
-
..._validate: HandlersAreValid<Handlers, DomainError> extends true
|
|
248
|
-
? []
|
|
249
|
-
: [
|
|
250
|
-
eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>,
|
|
251
|
-
]
|
|
252
270
|
) {
|
|
271
|
+
const config = builder(baseRespond);
|
|
253
272
|
const runtime = config.runtime;
|
|
254
273
|
const requestContextKey = config.requestContext;
|
|
274
|
+
// The single `Respond` an app uses: base helpers plus any app-specific ones.
|
|
275
|
+
// Base wins the merge so core helpers can't be shadowed.
|
|
276
|
+
const Respond = { ...config.respond, ...baseRespond } as BaseRespond & ExtraRespond;
|
|
255
277
|
|
|
256
278
|
// Uniform call signature for dispatch (the per-tag handler types are narrower).
|
|
257
279
|
// Defaults to an empty map when `errorHandlers` is omitted.
|
|
@@ -281,7 +303,7 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
281
303
|
DomainError
|
|
282
304
|
>,
|
|
283
305
|
]
|
|
284
|
-
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<
|
|
306
|
+
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>> {
|
|
285
307
|
return (args: Args) => {
|
|
286
308
|
// The internal channel is deliberately loose (`unknown` success); the outer
|
|
287
309
|
// cast restores the precise resolved type (computed from `E` and the handler
|
|
@@ -328,13 +350,14 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
328
350
|
const result = runtime
|
|
329
351
|
? runtime.runPromise(provided)
|
|
330
352
|
: Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>);
|
|
331
|
-
return result as Promise<A | DirectRecover<E> | RecoverOf<
|
|
353
|
+
return result as Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>>;
|
|
332
354
|
};
|
|
333
355
|
}
|
|
334
356
|
|
|
335
357
|
return {
|
|
336
358
|
makeLoader: makeLoaderOrAction,
|
|
337
359
|
makeAction: makeLoaderOrAction,
|
|
360
|
+
Respond,
|
|
338
361
|
};
|
|
339
362
|
};
|
|
340
363
|
}
|