react-router-effect 0.2.0 → 0.3.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 +41 -3
- package/dist/index.d.mts +36 -5
- package/dist/index.mjs +34 -11
- package/package.json +3 -3
- package/src/factory.ts +80 -40
package/README.md
CHANGED
|
@@ -140,11 +140,49 @@ class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
|
|
|
140
140
|
|
|
141
141
|
A registered handler, if present, still takes precedence over the error's own response.
|
|
142
142
|
|
|
143
|
+
### Providing services with a runtime
|
|
144
|
+
|
|
145
|
+
Pass a `runtime` (an effect [`ManagedRuntime`](https://effect.website)) and your loaders/actions
|
|
146
|
+
may require its services directly — no per-call `Effect.provide`. The runtime is built once and
|
|
147
|
+
reused for every request:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
// app/runtime.server.ts
|
|
151
|
+
import { ManagedRuntime } from "effect";
|
|
152
|
+
export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
|
|
153
|
+
|
|
154
|
+
// app/route.server.ts
|
|
155
|
+
export const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
156
|
+
runtime: appRuntime,
|
|
157
|
+
errorHandlers: { ... },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
|
|
161
|
+
const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
162
|
+
Effect.gen(function* () {
|
|
163
|
+
const svc = yield* MyService;
|
|
164
|
+
return { profile: yield* svc.load(args.params.id) };
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The runtime's services become the effect's allowed requirement channel: requiring a service the
|
|
170
|
+
runtime provides type-checks, while requiring one it _doesn't_ is a compile error. With no
|
|
171
|
+
`runtime`, effects must require nothing.
|
|
172
|
+
|
|
173
|
+
`errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
makeLoaderOrActionFactory()({ runtime });
|
|
177
|
+
makeLoaderOrActionFactory()({});
|
|
178
|
+
```
|
|
179
|
+
|
|
143
180
|
## API
|
|
144
181
|
|
|
145
|
-
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers })`** →
|
|
146
|
-
(both are the same wrapper).
|
|
147
|
-
|
|
182
|
+
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime? })`** →
|
|
183
|
+
`{ makeLoader, makeAction }` (both are the same wrapper). Both config fields are optional. A
|
|
184
|
+
non-domain error left in a loader/action's error channel — or a required service the `runtime`
|
|
185
|
+
doesn't provide — is a compile error.
|
|
148
186
|
- **`Respond`** — `early` (recover), `throw`, `redirect`.
|
|
149
187
|
- **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
|
|
150
188
|
route errors, and **`isRouteError`** to narrow them.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Effect, ManagedRuntime } from "effect";
|
|
2
2
|
import { HttpServerRespondable } from "effect/unstable/http";
|
|
3
3
|
import { ActionFunctionArgs, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } from "react-router";
|
|
4
4
|
|
|
@@ -145,12 +145,43 @@ type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] e
|
|
|
145
145
|
* to a 500. **Any other error** — a service-specific error the route consumes that
|
|
146
146
|
* isn't a declared domain error — *must* be handled in the loader/action, or
|
|
147
147
|
* `makeLoader`/`makeAction` fails to type-check.
|
|
148
|
+
*
|
|
149
|
+
* Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
|
|
150
|
+
* to provide your app's services once. Loader/action effects may then require those
|
|
151
|
+
* services directly — no per-call `Effect.provide`:
|
|
152
|
+
*
|
|
153
|
+
* ```ts
|
|
154
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
155
|
+
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
156
|
+
* errorHandlers: { ... },
|
|
157
|
+
* });
|
|
158
|
+
*
|
|
159
|
+
* // `MyService` is satisfied by the runtime, not provided here:
|
|
160
|
+
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
161
|
+
* Effect.gen(function* () {
|
|
162
|
+
* const svc = yield* MyService;
|
|
163
|
+
* return { data: yield* svc.load(args) };
|
|
164
|
+
* }),
|
|
165
|
+
* );
|
|
166
|
+
* ```
|
|
148
167
|
*/
|
|
149
|
-
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers>(config: {
|
|
150
|
-
|
|
168
|
+
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers = {}, RServices = never>(config: {
|
|
169
|
+
/**
|
|
170
|
+
* An optional handler per declared domain error. Omit entirely to register
|
|
171
|
+
* none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
|
|
172
|
+
* when there are no domain errors at all).
|
|
173
|
+
*/
|
|
174
|
+
errorHandlers?: Handlers;
|
|
175
|
+
/**
|
|
176
|
+
* The app runtime that provides services to loader/action effects. When
|
|
177
|
+
* set, effects may require its services (`RServices`, inferred from here)
|
|
178
|
+
* without providing layers, and runs go through `runtime.runPromise`. When
|
|
179
|
+
* omitted, `RServices` is `never` and effects must require nothing.
|
|
180
|
+
*/
|
|
181
|
+
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
151
182
|
}, ..._validate: HandlersAreValid<Handlers, DomainError> extends true ? [] : [eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>]) => {
|
|
152
|
-
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E,
|
|
153
|
-
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E,
|
|
183
|
+
makeLoader: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
184
|
+
makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, RServices>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
|
|
154
185
|
};
|
|
155
186
|
//#endregion
|
|
156
187
|
export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
package/dist/index.mjs
CHANGED
|
@@ -79,21 +79,44 @@ const processRouteError = (e) => {
|
|
|
79
79
|
* to a 500. **Any other error** — a service-specific error the route consumes that
|
|
80
80
|
* isn't a declared domain error — *must* be handled in the loader/action, or
|
|
81
81
|
* `makeLoader`/`makeAction` fails to type-check.
|
|
82
|
+
*
|
|
83
|
+
* Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
|
|
84
|
+
* to provide your app's services once. Loader/action effects may then require those
|
|
85
|
+
* services directly — no per-call `Effect.provide`:
|
|
86
|
+
*
|
|
87
|
+
* ```ts
|
|
88
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
89
|
+
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
90
|
+
* errorHandlers: { ... },
|
|
91
|
+
* });
|
|
92
|
+
*
|
|
93
|
+
* // `MyService` is satisfied by the runtime, not provided here:
|
|
94
|
+
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
95
|
+
* Effect.gen(function* () {
|
|
96
|
+
* const svc = yield* MyService;
|
|
97
|
+
* return { data: yield* svc.load(args) };
|
|
98
|
+
* }),
|
|
99
|
+
* );
|
|
100
|
+
* ```
|
|
82
101
|
*/
|
|
83
102
|
function makeLoaderOrActionFactory() {
|
|
84
103
|
return function defineErrorHandlers(config, ..._validate) {
|
|
85
|
-
const
|
|
86
|
-
const userHandlers = config.errorHandlers;
|
|
104
|
+
const runtime = config.runtime;
|
|
105
|
+
const userHandlers = config.errorHandlers ?? {};
|
|
106
|
+
const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
|
|
87
107
|
function makeLoaderOrAction(fn, ..._handle) {
|
|
88
|
-
return (args) =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
return (args) => {
|
|
109
|
+
const program = fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
|
|
110
|
+
if (isUserError(e)) {
|
|
111
|
+
const out = userHandlers[e._tag](e);
|
|
112
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
113
|
+
}
|
|
114
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
115
|
+
if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
|
|
116
|
+
return internalServerError();
|
|
117
|
+
}));
|
|
118
|
+
return runtime ? runtime.runPromise(program) : Effect.runPromise(program);
|
|
119
|
+
};
|
|
97
120
|
}
|
|
98
121
|
return {
|
|
99
122
|
makeLoader: makeLoaderOrAction,
|
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.3.0",
|
|
5
5
|
"description": "Wrap React Router loaders/actions with Effect, with typed recover/throw/redirect error handling.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"action",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"playwright": "1.60.0",
|
|
49
49
|
"react": "^19.2.7",
|
|
50
50
|
"react-dom": "^19.2.7",
|
|
51
|
-
"react-router": "^
|
|
51
|
+
"react-router": "^8.0.0",
|
|
52
52
|
"typescript": "^6.0.3",
|
|
53
53
|
"vite-plus": "latest",
|
|
54
54
|
"vitest": "4.1.9",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"effect": "^4.0.0-beta.88",
|
|
59
|
-
"react-router": "^7.16.0"
|
|
59
|
+
"react-router": "^7.16.0 || ^8.0.0"
|
|
60
60
|
},
|
|
61
61
|
"devEngines": {
|
|
62
62
|
"packageManager": {
|
package/src/factory.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Effect, type ManagedRuntime } from "effect";
|
|
2
2
|
import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
|
|
3
3
|
import {
|
|
4
4
|
data,
|
|
@@ -162,10 +162,43 @@ const processRouteError = (
|
|
|
162
162
|
* to a 500. **Any other error** — a service-specific error the route consumes that
|
|
163
163
|
* isn't a declared domain error — *must* be handled in the loader/action, or
|
|
164
164
|
* `makeLoader`/`makeAction` fails to type-check.
|
|
165
|
+
*
|
|
166
|
+
* Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
|
|
167
|
+
* to provide your app's services once. Loader/action effects may then require those
|
|
168
|
+
* services directly — no per-call `Effect.provide`:
|
|
169
|
+
*
|
|
170
|
+
* ```ts
|
|
171
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
172
|
+
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
173
|
+
* errorHandlers: { ... },
|
|
174
|
+
* });
|
|
175
|
+
*
|
|
176
|
+
* // `MyService` is satisfied by the runtime, not provided here:
|
|
177
|
+
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
178
|
+
* Effect.gen(function* () {
|
|
179
|
+
* const svc = yield* MyService;
|
|
180
|
+
* return { data: yield* svc.load(args) };
|
|
181
|
+
* }),
|
|
182
|
+
* );
|
|
183
|
+
* ```
|
|
165
184
|
*/
|
|
166
185
|
export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
|
|
167
|
-
return function defineErrorHandlers<const Handlers>(
|
|
168
|
-
config: {
|
|
186
|
+
return function defineErrorHandlers<const Handlers = {}, RServices = never>(
|
|
187
|
+
config: {
|
|
188
|
+
/**
|
|
189
|
+
* An optional handler per declared domain error. Omit entirely to register
|
|
190
|
+
* none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
|
|
191
|
+
* when there are no domain errors at all).
|
|
192
|
+
*/
|
|
193
|
+
errorHandlers?: Handlers;
|
|
194
|
+
/**
|
|
195
|
+
* The app runtime that provides services to loader/action effects. When
|
|
196
|
+
* set, effects may require its services (`RServices`, inferred from here)
|
|
197
|
+
* without providing layers, and runs go through `runtime.runPromise`. When
|
|
198
|
+
* omitted, `RServices` is `never` and effects must require nothing.
|
|
199
|
+
*/
|
|
200
|
+
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
201
|
+
},
|
|
169
202
|
// Validation: a handler for a non-domain error, or one that doesn't return a
|
|
170
203
|
// route error / failing `Effect`, makes this rest parameter required and forces
|
|
171
204
|
// a compile error at the call.
|
|
@@ -175,20 +208,22 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
175
208
|
eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>,
|
|
176
209
|
]
|
|
177
210
|
) {
|
|
178
|
-
const
|
|
179
|
-
typeof e === "object" &&
|
|
180
|
-
e !== null &&
|
|
181
|
-
"_tag" in e &&
|
|
182
|
-
(e as Tagged)._tag in (config.errorHandlers as object);
|
|
211
|
+
const runtime = config.runtime;
|
|
183
212
|
|
|
184
213
|
// Uniform call signature for dispatch (the per-tag handler types are narrower).
|
|
185
|
-
|
|
214
|
+
// Defaults to an empty map when `errorHandlers` is omitted.
|
|
215
|
+
const userHandlers = (config.errorHandlers ?? {}) as unknown as Record<
|
|
186
216
|
string,
|
|
187
217
|
ErrorHandler<DomainError>
|
|
188
218
|
>;
|
|
189
219
|
|
|
220
|
+
const isUserError = (e: unknown): e is DomainError =>
|
|
221
|
+
typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
|
|
222
|
+
|
|
190
223
|
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
|
|
191
|
-
|
|
224
|
+
// The effect may require `RServices` — the services the configured `runtime`
|
|
225
|
+
// provides (or `never` when no runtime is configured, requiring nothing).
|
|
226
|
+
fn: (args: Args) => Effect.Effect<A, E, RServices>,
|
|
192
227
|
// If the effect can still fail with something the library won't handle — a
|
|
193
228
|
// service-specific error that isn't a declared domain error, a library route
|
|
194
229
|
// error, or respondable — this rest parameter becomes required and the call
|
|
@@ -202,40 +237,45 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
202
237
|
>,
|
|
203
238
|
]
|
|
204
239
|
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
|
|
205
|
-
return (args: Args) =>
|
|
240
|
+
return (args: Args) => {
|
|
206
241
|
// The internal channel is deliberately loose (`unknown` success); the outer
|
|
207
242
|
// cast restores the precise resolved type (computed from `E` and the handler
|
|
208
243
|
// map). Sound at runtime — the values produced are exactly those types.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
Effect.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
},
|
|
236
|
-
),
|
|
244
|
+
const program = fn(args).pipe(
|
|
245
|
+
// Catch the whole error channel and dispatch. The refinement is `e is E`
|
|
246
|
+
// (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
|
|
247
|
+
// overload crashes tsc over a generic `E`). A declared domain error with
|
|
248
|
+
// no handler (and not respondable) falls through to the 500 default.
|
|
249
|
+
Effect.catchIf(
|
|
250
|
+
(_e): _e is E => true,
|
|
251
|
+
(e): Effect.Effect<unknown, FailureResponse> => {
|
|
252
|
+
// Registered domain error → remap. A library-error return is processed
|
|
253
|
+
// by the internal dispatch; an `Effect` return is used as-is.
|
|
254
|
+
if (isUserError(e)) {
|
|
255
|
+
const out = userHandlers[e._tag](e);
|
|
256
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
257
|
+
}
|
|
258
|
+
// A library route error raised directly in the loader → recover/throw.
|
|
259
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
260
|
+
// Respondable → render its own response and throw it.
|
|
261
|
+
if (HttpServerRespondable.isRespondable(e)) {
|
|
262
|
+
return HttpServerRespondable.toResponse(e).pipe(
|
|
263
|
+
Effect.flatMap((res) =>
|
|
264
|
+
Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return internalServerError();
|
|
269
|
+
},
|
|
237
270
|
),
|
|
238
|
-
)
|
|
271
|
+
);
|
|
272
|
+
// Run against the configured runtime so its services satisfy the effect's
|
|
273
|
+
// `R`; with no runtime, the effect requires nothing and runs standalone.
|
|
274
|
+
const result = runtime
|
|
275
|
+
? runtime.runPromise(program)
|
|
276
|
+
: Effect.runPromise(program as Effect.Effect<unknown, FailureResponse>);
|
|
277
|
+
return result as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
|
|
278
|
+
};
|
|
239
279
|
}
|
|
240
280
|
|
|
241
281
|
return {
|