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 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 })`** → `{ makeLoader, makeAction }`
146
- (both are the same wrapper). A non-domain error left in a loader/action's error channel is a
147
- compile error.
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
- errorHandlers: Handlers;
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, never>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<E>>;
153
- makeAction: <Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E>(fn: (args: Args) => Effect.Effect<A, E, never>, ..._handle: [Unhandled<E, DomainError>] extends [never] ? [] : [theseErrorsAreNotDomainErrorsAndMustBeHandledInTheLoaderOrAction: Exclude<E, LibraryHandled<DomainError>>]) => (args: Args) => Promise<A | RecoverOf<Handlers, E> | DirectRecover<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 isUserError = (e) => typeof e === "object" && e !== null && "_tag" in e && e._tag in config.errorHandlers;
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) => Effect.runPromise(fn(args).pipe(Effect.catchIf((_e) => true, (e) => {
89
- if (isUserError(e)) {
90
- const out = userHandlers[e._tag](e);
91
- return isRouteError(out) ? processRouteError(out) : out;
92
- }
93
- if (isRouteError(e)) return processRouteError(e);
94
- if (HttpServerRespondable.isRespondable(e)) return HttpServerRespondable.toResponse(e).pipe(Effect.flatMap((res) => Effect.fail(HttpServerResponse.toWeb(res))));
95
- return internalServerError();
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.2.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": "^7.16.0",
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: { errorHandlers: Handlers },
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 isUserError = (e: unknown): e is DomainError =>
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
- const userHandlers = config.errorHandlers as unknown as Record<
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
- fn: (args: Args) => Effect.Effect<A, E>,
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
- Effect.runPromise(
210
- fn(args).pipe(
211
- // Catch the whole error channel and dispatch. The refinement is `e is E`
212
- // (provably E, and a *refinement* not a bare predicate the predicate
213
- // overload crashes tsc over a generic `E`). A declared domain error with
214
- // no handler (and not respondable) falls through to the 500 default.
215
- Effect.catchIf(
216
- (_e): _e is E => true,
217
- (e): Effect.Effect<unknown, FailureResponse> => {
218
- // Registered domain error remap. A library-error return is processed
219
- // by the internal dispatch; an `Effect` return is used as-is.
220
- if (isUserError(e)) {
221
- const out = userHandlers[e._tag](e);
222
- return isRouteError(out) ? processRouteError(out) : out;
223
- }
224
- // A library route error raised directly in the loader → recover/throw.
225
- if (isRouteError(e)) return processRouteError(e);
226
- // Respondable → render its own response and throw it.
227
- if (HttpServerRespondable.isRespondable(e)) {
228
- return HttpServerRespondable.toResponse(e).pipe(
229
- Effect.flatMap((res) =>
230
- Effect.fail<FailureResponse>(HttpServerResponse.toWeb(res)),
231
- ),
232
- );
233
- }
234
- return internalServerError();
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
- ) as Promise<A | DirectRecover<E> | RecoverOf<Handlers, E>>;
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 {