react-router-effect 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -33
- package/dist/index.d.mts +75 -22
- package/dist/index.mjs +37 -21
- package/package.json +1 -1
- package/src/errors.ts +10 -7
- package/src/factory.ts +118 -53
- package/src/index.ts +0 -1
package/README.md
CHANGED
|
@@ -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
|
|
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
|
|
69
|
+
import { makeLoaderOrActionFactory } 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 }> {}
|
|
@@ -74,27 +76,31 @@ class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
|
|
|
74
76
|
// so it falls through to the 500 default.
|
|
75
77
|
type DomainErrors = FormError | BadInputError | DbError;
|
|
76
78
|
|
|
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
|
-
}
|
|
79
|
+
// Curried: pin the domain errors, then the handler types are inferred. The config
|
|
80
|
+
// is a *builder* — it receives `Respond` (the library's `early`/`throw`/`redirect`)
|
|
81
|
+
// so your handlers can recover/throw, and the factory hands back a single `Respond`
|
|
82
|
+
// for your routes to import (no auto-import ambiguity with a library export).
|
|
83
|
+
export const { makeLoader, makeAction, Respond } = makeLoaderOrActionFactory<DomainErrors>()(
|
|
84
|
+
(Respond) => ({
|
|
85
|
+
// App-specific helpers — merged onto the returned `Respond` (base helpers win):
|
|
86
|
+
respond: {
|
|
87
|
+
formError: (reply: SubmissionResponse) => new FormError({ reply }),
|
|
88
|
+
},
|
|
89
|
+
errorHandlers: {
|
|
90
|
+
// keys autocomplete to your domain-error tags; `error` is typed from its key.
|
|
91
|
+
// recover: short-circuit and hand the reply to the component
|
|
92
|
+
FormError: (error) => Respond.early({ reply: error.reply }),
|
|
93
|
+
// throw: send to the error boundary
|
|
94
|
+
BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
93
98
|
```
|
|
94
99
|
|
|
95
|
-
> **
|
|
96
|
-
> `
|
|
97
|
-
>
|
|
100
|
+
> **One `Respond`, no annotations.** Your routes import the `Respond` returned here — there's
|
|
101
|
+
> no library-level `Respond` to clash with it on auto-import. Handler keys autocomplete to your
|
|
102
|
+
> declared domain-error tags, each `error` parameter is typed from its key, and the precise
|
|
103
|
+
> recover types are derived from each handler's return.
|
|
98
104
|
|
|
99
105
|
### 2. Write loaders/actions as effects
|
|
100
106
|
|
|
@@ -153,10 +159,10 @@ import { ManagedRuntime } from "effect";
|
|
|
153
159
|
export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
|
|
154
160
|
|
|
155
161
|
// app/route.server.ts
|
|
156
|
-
export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
162
|
+
export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
157
163
|
runtime: appRuntime,
|
|
158
164
|
errorHandlers: { ... },
|
|
159
|
-
});
|
|
165
|
+
}));
|
|
160
166
|
|
|
161
167
|
// app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
|
|
162
168
|
const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
@@ -174,8 +180,8 @@ runtime provides type-checks, while requiring one it _doesn't_ is a compile erro
|
|
|
174
180
|
`errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
|
|
175
181
|
|
|
176
182
|
```ts
|
|
177
|
-
makeLoaderOrActionFactory()({ runtime });
|
|
178
|
-
makeLoaderOrActionFactory()({});
|
|
183
|
+
makeLoaderOrActionFactory()(() => ({ runtime }));
|
|
184
|
+
makeLoaderOrActionFactory()(() => ({}));
|
|
179
185
|
```
|
|
180
186
|
|
|
181
187
|
### Per-request services from middleware
|
|
@@ -210,10 +216,10 @@ export const middleware: Route.MiddlewareFunction[] = [
|
|
|
210
216
|
];
|
|
211
217
|
|
|
212
218
|
// app/route.server.ts — wire the same key into the factory:
|
|
213
|
-
export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
219
|
+
export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
214
220
|
runtime: appRuntime,
|
|
215
221
|
requestContext,
|
|
216
|
-
});
|
|
222
|
+
}));
|
|
217
223
|
|
|
218
224
|
// the loader requires RequestContext directly — no provide, fresh each request:
|
|
219
225
|
export const loader = makeLoader(() =>
|
|
@@ -230,13 +236,16 @@ else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
|
|
|
230
236
|
|
|
231
237
|
## API
|
|
232
238
|
|
|
233
|
-
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime?, requestContext? })`**
|
|
234
|
-
`{ makeLoader, makeAction }` (
|
|
235
|
-
|
|
236
|
-
|
|
239
|
+
- **`makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond? }))`**
|
|
240
|
+
→ `{ makeLoader, makeAction, Respond }` (the two makers are the same wrapper). The config is a
|
|
241
|
+
_builder_ that receives the base `Respond`; all its fields are optional. The returned `Respond` is
|
|
242
|
+
the base helpers merged with your `respond` extensions (base helpers win). A non-domain error left
|
|
243
|
+
in a loader/action's error channel — or a required service that neither the `runtime` nor the
|
|
244
|
+
`requestContext` provides — is a compile error.
|
|
237
245
|
- **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
|
|
238
246
|
effect context (`RouterContext<Context.Context<ReqServices>>`).
|
|
239
|
-
- **`Respond`** — `early` (recover), `throw`, `redirect
|
|
247
|
+
- **`Respond`** (returned from the factory) — `early` (recover), `throw`, `redirect`, plus any
|
|
248
|
+
`respond` helpers you add.
|
|
240
249
|
- **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
|
|
241
250
|
route errors, and **`isRouteError`** to narrow them.
|
|
242
251
|
- **`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
|
};
|
|
@@ -119,6 +124,35 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
|
|
|
119
124
|
* errors. The loader/action must handle these itself (catch or map them).
|
|
120
125
|
*/
|
|
121
126
|
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
127
|
+
/**
|
|
128
|
+
* A readable compile-time diagnostic. It's intersected onto the *effect return type*
|
|
129
|
+
* of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
|
|
130
|
+
* reports this message, and the offending types, *at the `makeLoader`/`makeAction`
|
|
131
|
+
* call* instead of the opaque "Expected 2 arguments". Resolves to `unknown` (a no-op
|
|
132
|
+
* intersection) when the effect is fully handled and all its requirements are provided:
|
|
133
|
+
*
|
|
134
|
+
* - **Unhandled error** — the effect can fail with something that isn't a declared
|
|
135
|
+
* domain error, a library route error, or respondable; `unhandledErrors` names them.
|
|
136
|
+
* - **Missing requirement** — the effect requires a service that neither the
|
|
137
|
+
* `runtime` nor the `requestContext` provides; `missingRequirements` names them.
|
|
138
|
+
*
|
|
139
|
+
* The message lives in an *inline* object literal (no named alias) so tsc/tsgo print
|
|
140
|
+
* its text verbatim rather than collapsing it to an alias name.
|
|
141
|
+
*/
|
|
142
|
+
/**
|
|
143
|
+
* Names the unhandled errors for the diagnostic: a tagged error becomes its `_tag`
|
|
144
|
+
* (the string you'd pass to `Effect.catchTag`), anything untagged stays as its type.
|
|
145
|
+
*/
|
|
146
|
+
type NameError<U> = U extends {
|
|
147
|
+
readonly _tag: infer Tag extends string;
|
|
148
|
+
} ? Tag : U;
|
|
149
|
+
type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends [never] ? [Exclude<R, Provided>] extends [never] ? unknown : {
|
|
150
|
+
"react-router-effect": "This loader/action requires a service the factory does not provide. Add it to your runtime or requestContext, or handle it in the effect.";
|
|
151
|
+
missingRequirements: Exclude<R, Provided>;
|
|
152
|
+
} : {
|
|
153
|
+
"react-router-effect": "This loader/action can fail with an error the library does not handle. Catch it in the effect (e.g. Effect.catchTag), make it Respondable, or add it to your DomainError union.";
|
|
154
|
+
unhandledErrors: NameError<Unhandled<E, DomainError>>;
|
|
155
|
+
};
|
|
122
156
|
/**
|
|
123
157
|
* A React Router context key holding a per-request effect `Context.Context`. Set
|
|
124
158
|
* it in middleware and pass it to the factory's `requestContext` — the runner
|
|
@@ -159,15 +193,21 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
159
193
|
* ```ts
|
|
160
194
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
161
195
|
*
|
|
162
|
-
* const { makeLoader, makeAction } =
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
196
|
+
* export const { makeLoader, makeAction, Respond } =
|
|
197
|
+
* makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
|
|
198
|
+
* errorHandlers: {
|
|
199
|
+
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
200
|
+
* MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
|
|
201
|
+
* // DbError has no handler → falls through to the 500 default.
|
|
202
|
+
* },
|
|
203
|
+
* }));
|
|
169
204
|
* ```
|
|
170
205
|
*
|
|
206
|
+
* The inner config is a **builder** — it receives the library's `Respond` helpers so
|
|
207
|
+
* your handlers can recover/throw with them, and returns the config. `Respond` is
|
|
208
|
+
* also returned from the factory (extended with any `respond` helpers you add), so
|
|
209
|
+
* your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
|
|
210
|
+
*
|
|
171
211
|
* A handler *remaps* the error by returning either a library route error
|
|
172
212
|
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
173
213
|
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
@@ -184,10 +224,10 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
184
224
|
* services directly — no per-call `Effect.provide`:
|
|
185
225
|
*
|
|
186
226
|
* ```ts
|
|
187
|
-
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
227
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
188
228
|
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
189
229
|
* errorHandlers: { ... },
|
|
190
|
-
* });
|
|
230
|
+
* }));
|
|
191
231
|
*
|
|
192
232
|
* // `MyService` is satisfied by the runtime, not provided here:
|
|
193
233
|
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
@@ -198,13 +238,13 @@ type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>
|
|
|
198
238
|
* );
|
|
199
239
|
* ```
|
|
200
240
|
*/
|
|
201
|
-
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Returns extends HandlerReturns<DomainError> = {}, RServices = never, ReqServices = never>(
|
|
241
|
+
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Returns extends HandlerReturns<DomainError> = {}, RServices = never, ReqServices = never, const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {}>(builder: (respond: BaseRespond) => {
|
|
202
242
|
/**
|
|
203
243
|
* An optional handler per declared domain error, keyed by its tag. The keys
|
|
204
244
|
* autocomplete to your domain-error tags and each handler's `error` parameter
|
|
205
245
|
* is typed automatically — no annotation needed. A handler *remaps* the error
|
|
206
|
-
* by returning a library route error (`Respond.early` / `throw`
|
|
207
|
-
* or an `Effect` (`Effect.succeed`/`fail`). Omit
|
|
246
|
+
* by returning a library route error (the builder's `Respond.early` / `throw`
|
|
247
|
+
* / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
|
|
208
248
|
*/
|
|
209
249
|
errorHandlers?: ErrorHandlers<DomainError, Returns>;
|
|
210
250
|
/**
|
|
@@ -222,9 +262,22 @@ declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
222
262
|
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
223
263
|
*/
|
|
224
264
|
requestContext?: RequestContextKey<ReqServices>;
|
|
265
|
+
/**
|
|
266
|
+
* App-specific `Respond` helpers — merged onto the base `Respond` that the
|
|
267
|
+
* factory returns, so your app imports one `Respond`. Typically opinionated
|
|
268
|
+
* error constructors; annotate their parameters, e.g.
|
|
269
|
+
* `{ formError: (reply: string) => new FormError({ reply }) }`. The base
|
|
270
|
+
* helpers (`early` / `throw` / `redirect`) always win the merge.
|
|
271
|
+
*/
|
|
272
|
+
respond?: ExtraRespond;
|
|
225
273
|
}) => {
|
|
226
|
-
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E,
|
|
227
|
-
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E,
|
|
274
|
+
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E> | DirectRecover<E>>;
|
|
275
|
+
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(fn: (args: Args) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>) => (args: Args) => Promise<A | RecoverOf<DomainError, Returns, E> | DirectRecover<E>>;
|
|
276
|
+
Respond: {
|
|
277
|
+
early: <D>(value: D, init?: number | ResponseInit) => ReturnableDataError<D>;
|
|
278
|
+
throw: <D>(value: D, init?: number | ResponseInit) => ThrowableDataError<D>;
|
|
279
|
+
redirect: (url: string, init?: number | ResponseInit) => ThrowableRedirectError;
|
|
280
|
+
} & ExtraRespond;
|
|
228
281
|
};
|
|
229
282
|
//#endregion
|
|
230
|
-
export { type AnyRouteError, type ErrorHandler, type RequestContextKey,
|
|
283
|
+
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 = {
|
|
@@ -60,15 +63,21 @@ const processRouteError = (e) => {
|
|
|
60
63
|
* ```ts
|
|
61
64
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
62
65
|
*
|
|
63
|
-
* const { makeLoader, makeAction } =
|
|
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,14 +109,20 @@ 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
|
-
function makeLoaderOrAction(fn
|
|
122
|
+
function makeLoaderOrAction(fn) {
|
|
123
|
+
const run = fn;
|
|
109
124
|
return (args) => {
|
|
110
|
-
const program =
|
|
125
|
+
const program = run(args).pipe(Effect.catchIf((_e) => true, (e) => {
|
|
111
126
|
if (isUserError(e)) {
|
|
112
127
|
const out = userHandlers[e._tag](e);
|
|
113
128
|
return isRouteError(out) ? processRouteError(out) : out;
|
|
@@ -122,9 +137,10 @@ function makeLoaderOrActionFactory() {
|
|
|
122
137
|
}
|
|
123
138
|
return {
|
|
124
139
|
makeLoader: makeLoaderOrAction,
|
|
125
|
-
makeAction: makeLoaderOrAction
|
|
140
|
+
makeAction: makeLoaderOrAction,
|
|
141
|
+
Respond: Respond$1
|
|
126
142
|
};
|
|
127
143
|
};
|
|
128
144
|
}
|
|
129
145
|
//#endregion
|
|
130
|
-
export {
|
|
146
|
+
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.1",
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -108,6 +112,43 @@ type LibraryHandled<DomainError> = DomainError | AnyRouteError | HttpServerRespo
|
|
|
108
112
|
*/
|
|
109
113
|
type Unhandled<E, DomainError> = Exclude<E, LibraryHandled<DomainError>>;
|
|
110
114
|
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// The call-site diagnostic.
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A readable compile-time diagnostic. It's intersected onto the *effect return type*
|
|
121
|
+
* of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
|
|
122
|
+
* reports this message, and the offending types, *at the `makeLoader`/`makeAction`
|
|
123
|
+
* call* instead of the opaque "Expected 2 arguments". Resolves to `unknown` (a no-op
|
|
124
|
+
* intersection) when the effect is fully handled and all its requirements are provided:
|
|
125
|
+
*
|
|
126
|
+
* - **Unhandled error** — the effect can fail with something that isn't a declared
|
|
127
|
+
* domain error, a library route error, or respondable; `unhandledErrors` names them.
|
|
128
|
+
* - **Missing requirement** — the effect requires a service that neither the
|
|
129
|
+
* `runtime` nor the `requestContext` provides; `missingRequirements` names them.
|
|
130
|
+
*
|
|
131
|
+
* The message lives in an *inline* object literal (no named alias) so tsc/tsgo print
|
|
132
|
+
* its text verbatim rather than collapsing it to an alias name.
|
|
133
|
+
*/
|
|
134
|
+
/**
|
|
135
|
+
* Names the unhandled errors for the diagnostic: a tagged error becomes its `_tag`
|
|
136
|
+
* (the string you'd pass to `Effect.catchTag`), anything untagged stays as its type.
|
|
137
|
+
*/
|
|
138
|
+
type NameError<U> = U extends { readonly _tag: infer Tag extends string } ? Tag : U;
|
|
139
|
+
|
|
140
|
+
type Diagnose<E, R, DomainError, Provided> = [Unhandled<E, DomainError>] extends [never]
|
|
141
|
+
? [Exclude<R, Provided>] extends [never]
|
|
142
|
+
? unknown
|
|
143
|
+
: {
|
|
144
|
+
"react-router-effect": "This loader/action requires a service the factory does not provide. Add it to your runtime or requestContext, or handle it in the effect.";
|
|
145
|
+
missingRequirements: Exclude<R, Provided>;
|
|
146
|
+
}
|
|
147
|
+
: {
|
|
148
|
+
"react-router-effect": "This loader/action can fail with an error the library does not handle. Catch it in the effect (e.g. Effect.catchTag), make it Respondable, or add it to your DomainError union.";
|
|
149
|
+
unhandledErrors: NameError<Unhandled<E, DomainError>>;
|
|
150
|
+
};
|
|
151
|
+
|
|
111
152
|
// ---------------------------------------------------------------------------
|
|
112
153
|
// Internal runtime helpers.
|
|
113
154
|
// ---------------------------------------------------------------------------
|
|
@@ -173,15 +214,21 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
173
214
|
* ```ts
|
|
174
215
|
* type DomainErrors = MyDomainError | DbError | NotAuthorizedError;
|
|
175
216
|
*
|
|
176
|
-
* const { makeLoader, makeAction } =
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
217
|
+
* export const { makeLoader, makeAction, Respond } =
|
|
218
|
+
* makeLoaderOrActionFactory<DomainErrors>()((Respond) => ({
|
|
219
|
+
* errorHandlers: {
|
|
220
|
+
* // `error` is typed as MyDomainError automatically. throw → error boundary
|
|
221
|
+
* MyDomainError: (error) => Respond.throw({ message: error.message }, 400),
|
|
222
|
+
* // DbError has no handler → falls through to the 500 default.
|
|
223
|
+
* },
|
|
224
|
+
* }));
|
|
183
225
|
* ```
|
|
184
226
|
*
|
|
227
|
+
* The inner config is a **builder** — it receives the library's `Respond` helpers so
|
|
228
|
+
* your handlers can recover/throw with them, and returns the config. `Respond` is
|
|
229
|
+
* also returned from the factory (extended with any `respond` helpers you add), so
|
|
230
|
+
* your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
|
|
231
|
+
*
|
|
185
232
|
* A handler *remaps* the error by returning either a library route error
|
|
186
233
|
* (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
|
|
187
234
|
* `Effect` (`Effect.succeed(value)` to recover `value`; `Effect.fail(response)` to
|
|
@@ -198,10 +245,10 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
|
|
|
198
245
|
* services directly — no per-call `Effect.provide`:
|
|
199
246
|
*
|
|
200
247
|
* ```ts
|
|
201
|
-
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
248
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()(() => ({
|
|
202
249
|
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
203
250
|
* errorHandlers: { ... },
|
|
204
|
-
* });
|
|
251
|
+
* }));
|
|
205
252
|
*
|
|
206
253
|
* // `MyService` is satisfied by the runtime, not provided here:
|
|
207
254
|
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
@@ -217,33 +264,53 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
217
264
|
const Returns extends HandlerReturns<DomainError> = {},
|
|
218
265
|
RServices = never,
|
|
219
266
|
ReqServices = never,
|
|
220
|
-
|
|
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>;
|
|
267
|
+
const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {},
|
|
268
|
+
>(
|
|
236
269
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* services to the effect. Loader/action effects may then require
|
|
241
|
-
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
270
|
+
* Builds the factory config. It receives the library's base `Respond` helpers
|
|
271
|
+
* (`early` / `throw` / `redirect`) so your `errorHandlers` can recover/throw
|
|
272
|
+
* with them, and returns the config object.
|
|
242
273
|
*/
|
|
243
|
-
|
|
244
|
-
|
|
274
|
+
builder: (respond: BaseRespond) => {
|
|
275
|
+
/**
|
|
276
|
+
* An optional handler per declared domain error, keyed by its tag. The keys
|
|
277
|
+
* autocomplete to your domain-error tags and each handler's `error` parameter
|
|
278
|
+
* is typed automatically — no annotation needed. A handler *remaps* the error
|
|
279
|
+
* by returning a library route error (the builder's `Respond.early` / `throw`
|
|
280
|
+
* / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
|
|
281
|
+
*/
|
|
282
|
+
errorHandlers?: ErrorHandlers<DomainError, Returns>;
|
|
283
|
+
/**
|
|
284
|
+
* The app runtime that provides services to loader/action effects. When
|
|
285
|
+
* set, effects may require its services (`RServices`, inferred from here)
|
|
286
|
+
* without providing layers, and runs go through `runtime.runPromise`. When
|
|
287
|
+
* omitted, `RServices` is `never` and effects must require nothing.
|
|
288
|
+
*/
|
|
289
|
+
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
290
|
+
/**
|
|
291
|
+
* A React Router context key (a {@link RequestContextKey}) holding a
|
|
292
|
+
* per-request effect `Context.Context`. Middleware sets it for each request;
|
|
293
|
+
* the runner reads `args.context.get(requestContext)` and provides those
|
|
294
|
+
* services to the effect. Loader/action effects may then require
|
|
295
|
+
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
296
|
+
*/
|
|
297
|
+
requestContext?: RequestContextKey<ReqServices>;
|
|
298
|
+
/**
|
|
299
|
+
* App-specific `Respond` helpers — merged onto the base `Respond` that the
|
|
300
|
+
* factory returns, so your app imports one `Respond`. Typically opinionated
|
|
301
|
+
* error constructors; annotate their parameters, e.g.
|
|
302
|
+
* `{ formError: (reply: string) => new FormError({ reply }) }`. The base
|
|
303
|
+
* helpers (`early` / `throw` / `redirect`) always win the merge.
|
|
304
|
+
*/
|
|
305
|
+
respond?: ExtraRespond;
|
|
306
|
+
},
|
|
307
|
+
) {
|
|
308
|
+
const config = builder(baseRespond);
|
|
245
309
|
const runtime = config.runtime;
|
|
246
310
|
const requestContextKey = config.requestContext;
|
|
311
|
+
// The single `Respond` an app uses: base helpers plus any app-specific ones.
|
|
312
|
+
// Base wins the merge so core helpers can't be shadowed.
|
|
313
|
+
const Respond = { ...config.respond, ...baseRespond } as BaseRespond & ExtraRespond;
|
|
247
314
|
|
|
248
315
|
// Uniform call signature for dispatch (the per-tag handler types are narrower).
|
|
249
316
|
// Defaults to an empty map when `errorHandlers` is omitted.
|
|
@@ -255,34 +322,31 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
255
322
|
const isUserError = (e: unknown): e is DomainError =>
|
|
256
323
|
typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
|
|
257
324
|
|
|
258
|
-
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
..._handle: [Unhandled<E, DomainError>] extends [never]
|
|
269
|
-
? []
|
|
270
|
-
: [
|
|
271
|
-
theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Unhandled<
|
|
272
|
-
E,
|
|
273
|
-
DomainError
|
|
274
|
-
>,
|
|
275
|
-
]
|
|
325
|
+
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(
|
|
326
|
+
// `A`/`E`/`R` infer directly from the effect (naked, so inference stays robust).
|
|
327
|
+
// The diagnostic is intersected onto the effect's return type: when the effect
|
|
328
|
+
// requires a service the factory doesn't provide (`R` ⊄ runtime+requestContext),
|
|
329
|
+
// or can fail with something the library won't handle, `Diagnose` becomes a
|
|
330
|
+
// message object the effect isn't assignable to — so this argument fails to
|
|
331
|
+
// type-check with a readable explanation, and the offending types, right here.
|
|
332
|
+
fn: (
|
|
333
|
+
args: Args,
|
|
334
|
+
) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>,
|
|
276
335
|
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>> {
|
|
336
|
+
// The diagnostic is irrelevant inside the body (it's `unknown` for any valid
|
|
337
|
+
// call); treat `fn` as the plain effect-returning function it is.
|
|
338
|
+
const run = fn as unknown as (
|
|
339
|
+
args: Args,
|
|
340
|
+
) => Effect.Effect<unknown, E, RServices | ReqServices>;
|
|
277
341
|
return (args: Args) => {
|
|
278
342
|
// The internal channel is deliberately loose (`unknown` success); the outer
|
|
279
343
|
// cast restores the precise resolved type (computed from `E` and the handler
|
|
280
344
|
// map). Sound at runtime — the values produced are exactly those types.
|
|
281
|
-
const program =
|
|
345
|
+
const program = run(args).pipe(
|
|
282
346
|
// Catch the whole error channel and dispatch. The refinement is `e is E`
|
|
283
347
|
// (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
|
|
284
|
-
// overload crashes tsc over a generic `E`). A declared domain error with
|
|
285
|
-
//
|
|
348
|
+
// overload crashes tsc over a generic `E`). A declared domain error with no
|
|
349
|
+
// handler (and not respondable) falls through to the 500 default.
|
|
286
350
|
Effect.catchIf(
|
|
287
351
|
(_e): _e is E => true,
|
|
288
352
|
(e): Effect.Effect<unknown, FailureResponse> => {
|
|
@@ -327,6 +391,7 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
327
391
|
return {
|
|
328
392
|
makeLoader: makeLoaderOrAction,
|
|
329
393
|
makeAction: makeLoaderOrAction,
|
|
394
|
+
Respond,
|
|
330
395
|
};
|
|
331
396
|
};
|
|
332
397
|
}
|