react-router-effect 0.2.0 → 0.4.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 +93 -3
- package/dist/index.d.mts +74 -7
- package/dist/index.mjs +36 -11
- package/package.json +3 -3
- package/src/factory.ts +134 -40
- package/src/index.ts +1 -1
package/README.md
CHANGED
|
@@ -140,11 +140,101 @@ 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
|
+
|
|
180
|
+
### Per-request services from middleware
|
|
181
|
+
|
|
182
|
+
The runtime provides app-wide services. For **request-scoped** services — the current user, a
|
|
183
|
+
request id, a per-request transaction — use [middleware](https://reactrouter.com/how-to/middleware).
|
|
184
|
+
Middleware sets a fresh effect `Context` on a React Router context key each request; the factory's
|
|
185
|
+
`requestContext` reads it and provides those services to the loader/action:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
// app/request-context.server.ts
|
|
189
|
+
import { Context } from "effect";
|
|
190
|
+
import { createContext } from "react-router";
|
|
191
|
+
import type { RequestContextKey } from "react-router-effect";
|
|
192
|
+
|
|
193
|
+
class RequestContext extends Context.Service<
|
|
194
|
+
RequestContext,
|
|
195
|
+
{
|
|
196
|
+
readonly userId: string;
|
|
197
|
+
}
|
|
198
|
+
>()("app/RequestContext") {}
|
|
199
|
+
|
|
200
|
+
// `requestContext` is a plain React Router context key — `createContext` is RR's own.
|
|
201
|
+
export const requestContext: RequestContextKey<RequestContext> = createContext();
|
|
202
|
+
|
|
203
|
+
// app/routes/profile.ts — middleware sets a fresh value per request:
|
|
204
|
+
export const middleware: Route.MiddlewareFunction[] = [
|
|
205
|
+
({ context, request }, next) => {
|
|
206
|
+
context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
|
|
207
|
+
return next();
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
// app/route.server.ts — wire the same key into the factory:
|
|
212
|
+
export const { makeLoader } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
213
|
+
runtime: appRuntime,
|
|
214
|
+
requestContext,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// the loader requires RequestContext directly — no provide, fresh each request:
|
|
218
|
+
export const loader = makeLoader(() =>
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
const { userId } = yield* RequestContext;
|
|
221
|
+
return { userId };
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Effects may now require both the runtime's services and the request context's; requiring anything
|
|
227
|
+
else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
|
|
228
|
+
`RouterContext<Context.Context<ReqServices>>` — sugar for annotating the key, nothing more.
|
|
229
|
+
|
|
143
230
|
## API
|
|
144
231
|
|
|
145
|
-
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers })`** →
|
|
146
|
-
(both are the same wrapper).
|
|
147
|
-
|
|
232
|
+
- **`makeLoaderOrActionFactory<DomainErrors>()({ errorHandlers?, runtime?, requestContext? })`** →
|
|
233
|
+
`{ makeLoader, makeAction }` (both are the same wrapper). All config fields are optional. A
|
|
234
|
+
non-domain error left in a loader/action's error channel — or a required service that neither the
|
|
235
|
+
`runtime` nor the `requestContext` provides — is a compile error.
|
|
236
|
+
- **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
|
|
237
|
+
effect context (`RouterContext<Context.Context<ReqServices>>`).
|
|
148
238
|
- **`Respond`** — `early` (recover), `throw`, `redirect`.
|
|
149
239
|
- **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
|
|
150
240
|
route errors, and **`isRouteError`** to narrow them.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Effect } from "effect";
|
|
1
|
+
import { Context, Effect, ManagedRuntime } from "effect";
|
|
2
2
|
import { HttpServerRespondable } from "effect/unstable/http";
|
|
3
|
-
import { ActionFunctionArgs, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } from "react-router";
|
|
3
|
+
import { ActionFunctionArgs, LoaderFunctionArgs, RouterContext, UNSAFE_DataWithResponseInit } from "react-router";
|
|
4
4
|
|
|
5
5
|
//#region src/errors.d.ts
|
|
6
6
|
declare const ReturnableDataError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
|
|
@@ -114,6 +114,34 @@ type ValidHandlers<DomainError extends Tagged> = { [Tag in DomainError["_tag"]]?
|
|
|
114
114
|
* (`keyof Handlers` escaping the domain tags) or with a bad return makes it `false`.
|
|
115
115
|
*/
|
|
116
116
|
type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] extends [DomainError["_tag"]] ? Handlers extends ValidHandlers<DomainError> ? true : false : false;
|
|
117
|
+
/**
|
|
118
|
+
* A React Router context key holding a per-request effect `Context.Context`. Set
|
|
119
|
+
* it in middleware and pass it to the factory's `requestContext` — the runner
|
|
120
|
+
* reads it on every request and provides its services to the loader/action.
|
|
121
|
+
*
|
|
122
|
+
* It's just a `RouterContext`; create one with React Router's `createContext`:
|
|
123
|
+
*
|
|
124
|
+
* ```ts
|
|
125
|
+
* import { createContext } from "react-router";
|
|
126
|
+
* import { Context } from "effect";
|
|
127
|
+
* import type { RequestContextKey } from "react-router-effect";
|
|
128
|
+
*
|
|
129
|
+
* class RequestContext extends Context.Service<RequestContext, {
|
|
130
|
+
* readonly userId: string;
|
|
131
|
+
* }>()("app/RequestContext") {}
|
|
132
|
+
*
|
|
133
|
+
* export const requestContext: RequestContextKey<RequestContext> = createContext();
|
|
134
|
+
*
|
|
135
|
+
* // middleware:
|
|
136
|
+
* export const middleware: Route.MiddlewareFunction[] = [
|
|
137
|
+
* ({ context, request }, next) => {
|
|
138
|
+
* context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
|
|
139
|
+
* return next();
|
|
140
|
+
* },
|
|
141
|
+
* ];
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
|
|
117
145
|
/**
|
|
118
146
|
* Build `makeLoader` / `makeAction` for an application, wired to its domain errors.
|
|
119
147
|
*
|
|
@@ -145,12 +173,51 @@ type HandlersAreValid<Handlers, DomainError extends Tagged> = [keyof Handlers] e
|
|
|
145
173
|
* to a 500. **Any other error** — a service-specific error the route consumes that
|
|
146
174
|
* isn't a declared domain error — *must* be handled in the loader/action, or
|
|
147
175
|
* `makeLoader`/`makeAction` fails to type-check.
|
|
176
|
+
*
|
|
177
|
+
* Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
|
|
178
|
+
* to provide your app's services once. Loader/action effects may then require those
|
|
179
|
+
* services directly — no per-call `Effect.provide`:
|
|
180
|
+
*
|
|
181
|
+
* ```ts
|
|
182
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
183
|
+
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
184
|
+
* errorHandlers: { ... },
|
|
185
|
+
* });
|
|
186
|
+
*
|
|
187
|
+
* // `MyService` is satisfied by the runtime, not provided here:
|
|
188
|
+
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
189
|
+
* Effect.gen(function* () {
|
|
190
|
+
* const svc = yield* MyService;
|
|
191
|
+
* return { data: yield* svc.load(args) };
|
|
192
|
+
* }),
|
|
193
|
+
* );
|
|
194
|
+
* ```
|
|
148
195
|
*/
|
|
149
|
-
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers>(config: {
|
|
150
|
-
|
|
196
|
+
declare function makeLoaderOrActionFactory<DomainError extends Tagged = never>(): <const Handlers = {}, RServices = never, ReqServices = never>(config: {
|
|
197
|
+
/**
|
|
198
|
+
* An optional handler per declared domain error. Omit entirely to register
|
|
199
|
+
* none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
|
|
200
|
+
* when there are no domain errors at all).
|
|
201
|
+
*/
|
|
202
|
+
errorHandlers?: Handlers;
|
|
203
|
+
/**
|
|
204
|
+
* The app runtime that provides services to loader/action effects. When
|
|
205
|
+
* set, effects may require its services (`RServices`, inferred from here)
|
|
206
|
+
* without providing layers, and runs go through `runtime.runPromise`. When
|
|
207
|
+
* omitted, `RServices` is `never` and effects must require nothing.
|
|
208
|
+
*/
|
|
209
|
+
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
210
|
+
/**
|
|
211
|
+
* A React Router context key (see {@link createRequestContext}) holding a
|
|
212
|
+
* per-request effect `Context.Context`. Middleware sets it for each request;
|
|
213
|
+
* the runner reads `args.context.get(requestContext)` and provides those
|
|
214
|
+
* services to the effect. Loader/action effects may then require
|
|
215
|
+
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
216
|
+
*/
|
|
217
|
+
requestContext?: RequestContextKey<ReqServices>;
|
|
151
218
|
}, ..._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,
|
|
219
|
+
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<Handlers, E> | DirectRecover<E>>;
|
|
220
|
+
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<Handlers, E> | DirectRecover<E>>;
|
|
154
221
|
};
|
|
155
222
|
//#endregion
|
|
156
|
-
export { type AnyRouteError, type ErrorHandler, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
|
223
|
+
export { type AnyRouteError, type ErrorHandler, type RequestContextKey, Respond, ReturnableDataError, ThrowableDataError, ThrowableRedirectError, isRouteError, makeLoaderOrActionFactory };
|
package/dist/index.mjs
CHANGED
|
@@ -79,21 +79,46 @@ 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
|
|
104
|
+
const runtime = config.runtime;
|
|
105
|
+
const requestContextKey = config.requestContext;
|
|
106
|
+
const userHandlers = config.errorHandlers ?? {};
|
|
107
|
+
const isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in userHandlers;
|
|
87
108
|
function makeLoaderOrAction(fn, ..._handle) {
|
|
88
|
-
return (args) =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
return (args) => {
|
|
110
|
+
const program = fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
|
|
111
|
+
if (isUserError(e)) {
|
|
112
|
+
const out = userHandlers[e._tag](e);
|
|
113
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
114
|
+
}
|
|
115
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
116
|
+
if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
|
|
117
|
+
return internalServerError();
|
|
118
|
+
}));
|
|
119
|
+
const provided = requestContextKey ? Effect.provideContext(program, args.context.get(requestContextKey)) : program;
|
|
120
|
+
return runtime ? runtime.runPromise(provided) : Effect.runPromise(provided);
|
|
121
|
+
};
|
|
97
122
|
}
|
|
98
123
|
return {
|
|
99
124
|
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.4.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 { type Context, Effect, type ManagedRuntime } from "effect";
|
|
2
2
|
import { HttpServerRespondable, HttpServerResponse } from "effect/unstable/http";
|
|
3
3
|
import {
|
|
4
4
|
data,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type ActionFunctionArgs,
|
|
7
7
|
type UNSAFE_DataWithResponseInit as DataWithResponseInit,
|
|
8
8
|
type LoaderFunctionArgs,
|
|
9
|
+
type RouterContext,
|
|
9
10
|
} from "react-router";
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -127,6 +128,39 @@ const processRouteError = (
|
|
|
127
128
|
return Effect.fail<FailureResponse>(redirect(e.url, e.init));
|
|
128
129
|
};
|
|
129
130
|
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Per-request context.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A React Router context key holding a per-request effect `Context.Context`. Set
|
|
137
|
+
* it in middleware and pass it to the factory's `requestContext` — the runner
|
|
138
|
+
* reads it on every request and provides its services to the loader/action.
|
|
139
|
+
*
|
|
140
|
+
* It's just a `RouterContext`; create one with React Router's `createContext`:
|
|
141
|
+
*
|
|
142
|
+
* ```ts
|
|
143
|
+
* import { createContext } from "react-router";
|
|
144
|
+
* import { Context } from "effect";
|
|
145
|
+
* import type { RequestContextKey } from "react-router-effect";
|
|
146
|
+
*
|
|
147
|
+
* class RequestContext extends Context.Service<RequestContext, {
|
|
148
|
+
* readonly userId: string;
|
|
149
|
+
* }>()("app/RequestContext") {}
|
|
150
|
+
*
|
|
151
|
+
* export const requestContext: RequestContextKey<RequestContext> = createContext();
|
|
152
|
+
*
|
|
153
|
+
* // middleware:
|
|
154
|
+
* export const middleware: Route.MiddlewareFunction[] = [
|
|
155
|
+
* ({ context, request }, next) => {
|
|
156
|
+
* context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
|
|
157
|
+
* return next();
|
|
158
|
+
* },
|
|
159
|
+
* ];
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
|
|
163
|
+
|
|
130
164
|
// ---------------------------------------------------------------------------
|
|
131
165
|
// Factory.
|
|
132
166
|
// ---------------------------------------------------------------------------
|
|
@@ -162,10 +196,51 @@ const processRouteError = (
|
|
|
162
196
|
* to a 500. **Any other error** — a service-specific error the route consumes that
|
|
163
197
|
* isn't a declared domain error — *must* be handled in the loader/action, or
|
|
164
198
|
* `makeLoader`/`makeAction` fails to type-check.
|
|
199
|
+
*
|
|
200
|
+
* Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
|
|
201
|
+
* to provide your app's services once. Loader/action effects may then require those
|
|
202
|
+
* services directly — no per-call `Effect.provide`:
|
|
203
|
+
*
|
|
204
|
+
* ```ts
|
|
205
|
+
* const { makeLoader, makeAction } = makeLoaderOrActionFactory<DomainErrors>()({
|
|
206
|
+
* runtime: getAppRuntime(), // provides Database, MyService, ...
|
|
207
|
+
* errorHandlers: { ... },
|
|
208
|
+
* });
|
|
209
|
+
*
|
|
210
|
+
* // `MyService` is satisfied by the runtime, not provided here:
|
|
211
|
+
* const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
212
|
+
* Effect.gen(function* () {
|
|
213
|
+
* const svc = yield* MyService;
|
|
214
|
+
* return { data: yield* svc.load(args) };
|
|
215
|
+
* }),
|
|
216
|
+
* );
|
|
217
|
+
* ```
|
|
165
218
|
*/
|
|
166
219
|
export function makeLoaderOrActionFactory<DomainError extends Tagged = never>() {
|
|
167
|
-
return function defineErrorHandlers<const Handlers>(
|
|
168
|
-
config: {
|
|
220
|
+
return function defineErrorHandlers<const Handlers = {}, RServices = never, ReqServices = never>(
|
|
221
|
+
config: {
|
|
222
|
+
/**
|
|
223
|
+
* An optional handler per declared domain error. Omit entirely to register
|
|
224
|
+
* none (e.g. when relying on `HttpServerRespondable` / the 500 default, or
|
|
225
|
+
* when there are no domain errors at all).
|
|
226
|
+
*/
|
|
227
|
+
errorHandlers?: Handlers;
|
|
228
|
+
/**
|
|
229
|
+
* The app runtime that provides services to loader/action effects. When
|
|
230
|
+
* set, effects may require its services (`RServices`, inferred from here)
|
|
231
|
+
* without providing layers, and runs go through `runtime.runPromise`. When
|
|
232
|
+
* omitted, `RServices` is `never` and effects must require nothing.
|
|
233
|
+
*/
|
|
234
|
+
runtime?: ManagedRuntime.ManagedRuntime<RServices, any>;
|
|
235
|
+
/**
|
|
236
|
+
* A React Router context key (see {@link createRequestContext}) holding a
|
|
237
|
+
* per-request effect `Context.Context`. Middleware sets it for each request;
|
|
238
|
+
* the runner reads `args.context.get(requestContext)` and provides those
|
|
239
|
+
* services to the effect. Loader/action effects may then require
|
|
240
|
+
* `ReqServices` (inferred from here) in addition to the runtime's services.
|
|
241
|
+
*/
|
|
242
|
+
requestContext?: RequestContextKey<ReqServices>;
|
|
243
|
+
},
|
|
169
244
|
// Validation: a handler for a non-domain error, or one that doesn't return a
|
|
170
245
|
// route error / failing `Effect`, makes this rest parameter required and forces
|
|
171
246
|
// a compile error at the call.
|
|
@@ -175,20 +250,25 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
175
250
|
eachHandlerMustBeForADeclaredDomainErrorAndReturnARouteErrorOrAnEffect: ValidHandlers<DomainError>,
|
|
176
251
|
]
|
|
177
252
|
) {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
e !== null &&
|
|
181
|
-
"_tag" in e &&
|
|
182
|
-
(e as Tagged)._tag in (config.errorHandlers as object);
|
|
253
|
+
const runtime = config.runtime;
|
|
254
|
+
const requestContextKey = config.requestContext;
|
|
183
255
|
|
|
184
256
|
// Uniform call signature for dispatch (the per-tag handler types are narrower).
|
|
185
|
-
|
|
257
|
+
// Defaults to an empty map when `errorHandlers` is omitted.
|
|
258
|
+
const userHandlers = (config.errorHandlers ?? {}) as unknown as Record<
|
|
186
259
|
string,
|
|
187
260
|
ErrorHandler<DomainError>
|
|
188
261
|
>;
|
|
189
262
|
|
|
263
|
+
const isUserError = (e: unknown): e is DomainError =>
|
|
264
|
+
typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
|
|
265
|
+
|
|
190
266
|
function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(
|
|
191
|
-
|
|
267
|
+
// The effect may require `RServices` (provided by the configured `runtime`)
|
|
268
|
+
// and `ReqServices` (provided per-request from `requestContext`). Both are
|
|
269
|
+
// `never` when their source isn't configured, so the effect must require
|
|
270
|
+
// nothing extra.
|
|
271
|
+
fn: (args: Args) => Effect.Effect<A, E, RServices | ReqServices>,
|
|
192
272
|
// If the effect can still fail with something the library won't handle — a
|
|
193
273
|
// service-specific error that isn't a declared domain error, a library route
|
|
194
274
|
// error, or respondable — this rest parameter becomes required and the call
|
|
@@ -202,40 +282,54 @@ export function makeLoaderOrActionFactory<DomainError extends Tagged = never>()
|
|
|
202
282
|
>,
|
|
203
283
|
]
|
|
204
284
|
): (args: Args) => Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>> {
|
|
205
|
-
return (args: Args) =>
|
|
285
|
+
return (args: Args) => {
|
|
206
286
|
// The internal channel is deliberately loose (`unknown` success); the outer
|
|
207
287
|
// cast restores the precise resolved type (computed from `E` and the handler
|
|
208
288
|
// 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
|
-
),
|
|
289
|
+
const program = fn(args).pipe(
|
|
290
|
+
// Catch the whole error channel and dispatch. The refinement is `e is E`
|
|
291
|
+
// (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
|
|
292
|
+
// overload crashes tsc over a generic `E`). A declared domain error with
|
|
293
|
+
// no handler (and not respondable) falls through to the 500 default.
|
|
294
|
+
Effect.catchIf(
|
|
295
|
+
(_e): _e is E => true,
|
|
296
|
+
(e): Effect.Effect<unknown, FailureResponse> => {
|
|
297
|
+
// Registered domain error → remap. A library-error return is processed
|
|
298
|
+
// by the internal dispatch; an `Effect` return is used as-is.
|
|
299
|
+
if (isUserError(e)) {
|
|
300
|
+
const out = userHandlers[e._tag](e);
|
|
301
|
+
return isRouteError(out) ? processRouteError(out) : out;
|
|
302
|
+
}
|
|
303
|
+
// A library route error raised directly in the loader → recover/throw.
|
|
304
|
+
if (isRouteError(e)) return processRouteError(e);
|
|
305
|
+
// Respondable → render its own response and throw it.
|
|
306
|
+
if (HttpServerRespondable.isRespondable(e)) {
|
|
307
|
+
return HttpServerRespondable.toResponse(e).pipe(
|
|
308
|
+
Effect.flatMap((res) =>
|
|
309
|
+
Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
return internalServerError();
|
|
314
|
+
},
|
|
237
315
|
),
|
|
238
|
-
)
|
|
316
|
+
);
|
|
317
|
+
// Provide the per-request context (set by middleware) so `ReqServices` are
|
|
318
|
+
// satisfied, leaving only the runtime's `RServices` in the requirements.
|
|
319
|
+
// The cast is sound: `provideContext` removes `ReqServices`, and when no
|
|
320
|
+
// `requestContext` is configured `ReqServices` is `never` (nothing removed).
|
|
321
|
+
const provided = (
|
|
322
|
+
requestContextKey
|
|
323
|
+
? Effect.provideContext(program, args.context.get(requestContextKey))
|
|
324
|
+
: program
|
|
325
|
+
) as Effect.Effect<unknown, FailureResponse, RServices>;
|
|
326
|
+
// Run against the configured runtime so its services satisfy the effect's
|
|
327
|
+
// `R`; with no runtime, the effect requires nothing and runs standalone.
|
|
328
|
+
const result = runtime
|
|
329
|
+
? runtime.runPromise(provided)
|
|
330
|
+
: Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>);
|
|
331
|
+
return result as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
|
|
332
|
+
};
|
|
239
333
|
}
|
|
240
334
|
|
|
241
335
|
return {
|
package/src/index.ts
CHANGED