react-router-effect 2.0.0 → 2.2.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/src/factory.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { type Context, Effect, type ManagedRuntime } from "effect";
1
+ import { Context, Effect, type ManagedRuntime, Predicate } from "effect";
2
2
  import { HttpServerRespondable, HttpServerResponse } from "effect/http";
3
3
  import {
4
+ createContext,
4
5
  data,
5
6
  redirect,
6
7
  type ActionFunctionArgs,
@@ -11,6 +12,7 @@ import {
11
12
  type RouterContextProvider,
12
13
  } from "react-router";
13
14
 
15
+ import { attachActionMeta, type WithActionMeta } from "./action-meta.ts";
14
16
  import {
15
17
  isRouteError,
16
18
  Respond as baseRespond,
@@ -26,7 +28,7 @@ type BaseRespond = typeof baseRespond;
26
28
  // Type-level plumbing.
27
29
  // ---------------------------------------------------------------------------
28
30
 
29
- type Tagged = { readonly _tag: string };
31
+ export type Tagged = { readonly _tag: string };
30
32
 
31
33
  /**
32
34
  * Everything a failure branch rejects with. React Router routes a thrown
@@ -34,13 +36,13 @@ type Tagged = { readonly _tag: string };
34
36
  * `Response` (including `redirect(...)`) as-is. Pinning every `Effect.fail` branch
35
37
  * to this union keeps inference from fixing the error channel to the first one.
36
38
  */
37
- type FailureResponse = DataWithResponseInit<unknown> | Response;
39
+ export type FailureResponse = DataWithResponseInit<unknown> | Response;
38
40
 
39
41
  /**
40
42
  * What a handler may return, to *remap* a domain error:
41
- * - a **library route error** — `Respond.early(value)` (recover), `Respond.throw(data)`
42
- * or `Respond.redirect(url)` (throw) — which the library then processes; or
43
- * - an **`Effect`** — `Effect.succeed(value)` to recover with `value`, or
43
+ * - a **library route error** - `Respond.early(value)` (recover), `Respond.throw(data)`
44
+ * or `Respond.redirect(url)` (throw) - which the library then processes; or
45
+ * - an **`Effect`** - `Effect.succeed(value)` to recover with `value`, or
44
46
  * `Effect.fail(response)` to throw `response`.
45
47
  */
46
48
  export type ErrorHandler<Err> = (
@@ -55,45 +57,45 @@ type ReturnableBodyOf<E> = E extends ReturnableDataError<infer Body> ? Body : ne
55
57
  * Guarded so a loader that never recovers directly doesn't leak
56
58
  * `DataWithResponseInit<never>` into its return type.
57
59
  */
58
- type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
60
+ export type DirectRecover<E> = [ReturnableBodyOf<E>] extends [never]
59
61
  ? never
60
62
  : DataWithResponseInit<ReturnableBodyOf<E>>;
61
63
 
62
64
  /** What a handler may return: a library route error or an `Effect` failing with a response. */
63
65
  type HandlerReturn = AnyRouteError | Effect.Effect<unknown, FailureResponse>;
64
66
 
65
- /** A `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) — `(args, next) => ...`. */
66
- type AnyMiddlewareFunction = (...args: never[]) => unknown;
67
+ /** A `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) - `(args, next) => ...`. */
68
+ export type AnyMiddlewareFunction = (...args: never[]) => unknown;
67
69
 
68
70
  /**
69
71
  * The `args` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) is called
70
- * with — inferred from the type argument so `makeMiddleware` can be pinned to a specific
72
+ * with - inferred from the type argument so `makeMiddleware` can be pinned to a specific
71
73
  * generated route's middleware type.
72
74
  */
73
- type MiddlewareArgsOf<Middleware extends AnyMiddlewareFunction> = Parameters<Middleware>[0];
75
+ export type MiddlewareArgsOf<Middleware extends AnyMiddlewareFunction> = Parameters<Middleware>[0];
74
76
 
75
77
  /**
76
78
  * The `next` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) is called
77
- * with — react-router's own `() => Promise<Result>`, not exported under its own name.
79
+ * with - react-router's own `() => Promise<Result>`, not exported under its own name.
78
80
  */
79
- type MiddlewareNextOf<Middleware extends AnyMiddlewareFunction> = Parameters<Middleware>[1];
81
+ export type MiddlewareNextOf<Middleware extends AnyMiddlewareFunction> = Parameters<Middleware>[1];
80
82
 
81
83
  /**
82
84
  * The `Result` a `Route.MiddlewareFunction` (or the base `MiddlewareFunction`) resolves
83
- * with — i.e. what its `next()` resolves to, and what the middleware itself may return.
85
+ * with - i.e. what its `next()` resolves to, and what the middleware itself may return.
84
86
  * Read through `Awaited` because react-router declares `next` as an *interface* with a
85
87
  * call signature (`MiddlewareNextFunction`), and a route's own may resolve synchronously.
86
88
  */
87
- type MiddlewareResultOf<Middleware extends AnyMiddlewareFunction> =
89
+ export type MiddlewareResultOf<Middleware extends AnyMiddlewareFunction> =
88
90
  MiddlewareNextOf<Middleware> extends (...args: never[]) => infer Result ? Awaited<Result> : never;
89
91
 
90
92
  /**
91
93
  * The inferred map of handler RETURN types, keyed by domain-error tag. Making the
92
- * factory generic over *this* (rather than the whole handler map) is what lets
93
- * `errorHandlers` contextually type each handler's `error` param from its key —
94
- * no annotation needed — while still capturing precise return types for recovery.
94
+ * factory generic over *this* (rather than the handler map as a whole) is what lets
95
+ * `errorHandlers` contextually type each handler's `error` param from its key -
96
+ * no annotation needed - while still capturing precise return types for recovery.
95
97
  */
96
- type HandlerReturns<DomainError extends Tagged> = Partial<
98
+ export type HandlerReturns<DomainError extends Tagged> = Partial<
97
99
  Record<DomainError["_tag"], HandlerReturn>
98
100
  >;
99
101
 
@@ -107,14 +109,14 @@ type ErrorHandlers<DomainError extends Tagged, Returns> = {
107
109
  };
108
110
 
109
111
  /**
110
- * What the registered handlers recover into loader/action data — from each
112
+ * What the registered handlers recover into loader/action data - from each
111
113
  * handler's return type, but only for handlers whose error can actually occur in
112
114
  * `E`:
113
115
  * - a remapped `ReturnableDataError<B>` recovers as `DataWithResponseInit<B>`;
114
116
  * - an `Effect.succeed(value)` recovers as `value`;
115
117
  * - throwables, redirects and `Effect.fail(...)` contribute nothing.
116
118
  */
117
- type RecoverOf<DomainError extends Tagged, Returns, E> = {
119
+ export type RecoverOf<DomainError extends Tagged, Returns, E> = {
118
120
  [Tag in keyof Returns]: [Extract<E, Extract<DomainError, { readonly _tag: Tag }>>] extends [never]
119
121
  ? never
120
122
  :
@@ -123,25 +125,25 @@ type RecoverOf<DomainError extends Tagged, Returns, E> = {
123
125
  }[keyof Returns];
124
126
 
125
127
  /**
126
- * Errors the library deals with on the handler's behalf — so the loader/action/
128
+ * Errors the library deals with on the handler's behalf - so the loader/action/
127
129
  * middleware needn't handle them itself:
128
- * - the app's **declared domain errors** (`DomainError`) — handled by a registered
130
+ * - the app's **declared domain errors** (`DomainError`) - handled by a registered
129
131
  * handler, or left to the 500 / auto-respond default;
130
132
  * - **library route errors** raised directly via `Respond`;
131
133
  * - anything that renders itself via **`HttpServerRespondable`**;
132
- * - `AlsoHandled` — extra, call-site-specific already-handled errors. Only
134
+ * - `AlsoHandled` - extra, call-site-specific already-handled errors. Only
133
135
  * `makeMiddleware` uses this, to admit the `FailureResponse` its `next()`
134
- * fails with (a downstream throw, already a finished response — the library
136
+ * fails with (a downstream throw, already a finished response - the library
135
137
  * re-throws it as-is, so the middleware needn't catch it just to re-raise it).
136
138
  */
137
- type LibraryHandled<DomainError, AlsoHandled = never> =
139
+ export type LibraryHandled<DomainError, AlsoHandled = never> =
138
140
  | DomainError
139
141
  | AnyRouteError
140
142
  | HttpServerRespondable.Respondable
141
143
  | AlsoHandled;
142
144
 
143
145
  /**
144
- * What remains in a handler's error channel that the library will NOT handle —
146
+ * What remains in a handler's error channel that the library will NOT handle -
145
147
  * i.e. service-specific errors the route consumes that aren't declared domain
146
148
  * errors. The loader/action/middleware must handle these itself (catch or map them).
147
149
  */
@@ -156,37 +158,37 @@ type Unhandled<E, DomainError, AlsoHandled = never> = Exclude<
156
158
 
157
159
  /**
158
160
  * A readable compile-time diagnostic. It's intersected onto the *effect return type*
159
- * of `fn` — so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
161
+ * of `fn` - so the effect's `A`/`E`/`R` still infer directly (naked), while a mismatch
160
162
  * reports this message, and the offending types, *at the `makeLoader`/`makeAction`
161
163
  * call* instead of the opaque "Expected 2 arguments". Resolves to `unknown` (a no-op
162
164
  * intersection) when the effect is fully handled and all its requirements are provided:
163
165
  *
164
- * - **Unhandled error** — the effect can fail with something that isn't a declared
165
- * domain error, a library route error, or respondable; `unhandledErrors` names them.
166
- * - **Missing requirement** — the effect requires a service that neither the
167
- * `runtime` nor the `requestContext` provides; `missingRequirements` names them.
166
+ * - **Unhandled error** - the effect can fail with something that isn't a declared
167
+ * domain error, a library route error, or respondable; `unhandledErrors` lists them.
168
+ * - **Missing requirement** - the effect requires a service that neither the
169
+ * `runtime` nor the `requestContext` provides; `missingRequirements` lists them.
168
170
  *
169
- * The message lives in an *inline* object literal (no named alias) so tsc/tsgo print
171
+ * The message is in an *inline* object literal (no named alias) so tsc/tsgo print
170
172
  * its text verbatim rather than collapsing it to an alias name.
171
173
  */
172
174
  /**
173
- * Names the unhandled errors for the diagnostic: a tagged error becomes its `_tag`
175
+ * Lists the unhandled errors for the diagnostic: a tagged error becomes its `_tag`
174
176
  * (the string you'd pass to `Effect.catchTag`), anything untagged stays as its type.
175
177
  */
176
- type NameError<U> = U extends { readonly _tag: infer Tag extends string } ? Tag : U;
178
+ export type NameError<U> = U extends { readonly _tag: infer Tag extends string } ? Tag : U;
177
179
 
178
180
  /**
179
- * True exactly when `T` is (or, for a union, includes) `any` — not `unknown`, not
181
+ * True exactly when `T` is (or, for a union, includes) `any` - not `unknown`, not
180
182
  * `never`, not any other type. An unrelated mistake elsewhere in the effect (a typo,
181
183
  * a call with the wrong number of arguments, a genuine syntax error) commonly makes
182
184
  * TypeScript infer `any` for `E`/`R` instead of reporting a clean type; running
183
185
  * `Diagnose` on that `any` produces a confusing "unhandled errors: any" message that
184
- * buries the real mistake. Guarding on this and bailing to `unknown` lets tsc's own,
185
- * on-site error surface instead. (`1 & T` is `any` when `T` is `any` — including when
186
- * `any` only appears as a union member, since intersection distributes over unions —
186
+ * buries the actual mistake. Guarding on this and bailing to `unknown` lets tsc's own,
187
+ * on-site error appear instead. (`1 & T` is `any` when `T` is `any` - including when
188
+ * `any` only appears as a union member, since intersection distributes over unions -
187
189
  * and `0` is assignable to `any` but not to any concrete type built from `1 & T`.)
188
190
  */
189
- type IsAny<T> = 0 extends 1 & T ? true : false;
191
+ export type IsAny<T> = 0 extends 1 & T ? true : false;
190
192
 
191
193
  type Diagnose<E, R, DomainError, Provided, AlsoHandled = never> =
192
194
  IsAny<E> extends true
@@ -213,7 +215,7 @@ const internalServerError = () =>
213
215
  Effect.fail<FailureResponse>(new Response("Internal Server Error", { status: 500 }));
214
216
 
215
217
  /**
216
- * A raw `FailureResponse` — a downstream `next()`'s rejection, in `makeMiddleware` —
218
+ * A raw `FailureResponse` - a downstream `next()`'s rejection, in `makeMiddleware` -
217
219
  * is already a finished response; re-throwing it as-is (rather than 500ing it) lets
218
220
  * a nested loader's redirect/thrown response pass back up through the middleware chain.
219
221
  */
@@ -239,9 +241,15 @@ const processRouteError = (
239
241
 
240
242
  /**
241
243
  * A React Router context key holding a per-request effect `Context.Context`. Set
242
- * it in middleware and pass it to the factory's `requestContext` — the runner
244
+ * it in middleware and pass it to the factory's `requestContext` - the runner
243
245
  * reads it on every request and provides its services to the loader/action.
244
246
  *
247
+ * With the server adapter, do not set it to a new context. The adapter sets it
248
+ * to the request fiber's context before React Router runs, and a new context
249
+ * replaces those services, such as `HttpServerRequest` and the server span.
250
+ * Provide request services with a router middleware on `ReactRouter.routes`.
251
+ * The middleware below is for an app without the adapter.
252
+ *
245
253
  * It's just a `RouterContext`; create one with React Router's `createContext`:
246
254
  *
247
255
  * ```ts
@@ -255,7 +263,7 @@ const processRouteError = (
255
263
  *
256
264
  * export const requestContext: RequestContextKey<RequestContext> = createContext();
257
265
  *
258
- * // middleware:
266
+ * // middleware, in an app without the server adapter:
259
267
  * export const middleware: Route.MiddlewareFunction[] = [
260
268
  * ({ context, request }, next) => {
261
269
  * context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
@@ -266,6 +274,99 @@ const processRouteError = (
266
274
  */
267
275
  export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqServices>>;
268
276
 
277
+ /**
278
+ * The request store of a factory that configures no `requestContext`. It holds
279
+ * whatever the server adapter and effect middleware put there, and starts empty.
280
+ */
281
+ const defaultRequestContext: RequestContextKey<never> = createContext(Context.empty());
282
+
283
+ /** What `actionMeta` reads: the request and the router context of the action. */
284
+ export interface ActionMetaArgs {
285
+ readonly request: Request;
286
+ readonly context: Readonly<RouterContextProvider>;
287
+ }
288
+
289
+ /**
290
+ * The entries of `current` that `started` lacks or holds with another value:
291
+ * what a fiber that began with `started` has provided since.
292
+ */
293
+ const providedSince = <Services>(
294
+ started: Context.Context<Services>,
295
+ current: Context.Context<never>,
296
+ ): Context.Context<never> => {
297
+ const provided = new Map<string, unknown>();
298
+ for (const [key, value] of current.mapUnsafe) {
299
+ if (started.mapUnsafe.get(key) !== value) provided.set(key, value);
300
+ }
301
+ return Context.makeUnsafe(provided);
302
+ };
303
+
304
+ /** The `next` an effect middleware gets: runs the rest of the chain as an effect. */
305
+ export type EffectNext<Result> = () => Effect.Effect<Result, FailureResponse>;
306
+
307
+ /**
308
+ * The arguments of an effect middleware: React Router's own middleware args,
309
+ * plus `next` as an effect. The second parameter stays React Router's
310
+ * promise-returning `next`, so a plain middleware works unchanged.
311
+ */
312
+ export type EffectMiddlewareArgs<Middleware extends AnyMiddlewareFunction = MiddlewareFunction> =
313
+ MiddlewareArgsOf<Middleware> & {
314
+ readonly next: EffectNext<MiddlewareResultOf<Middleware>>;
315
+ };
316
+
317
+ /** The router context and signal every route function receives. */
318
+ interface RouteArgs {
319
+ readonly request: Request;
320
+ readonly context: Readonly<RouterContextProvider>;
321
+ }
322
+
323
+ /**
324
+ * What a route function resolves with: an Effect's success, or the awaited
325
+ * value. A failure that a handler recovers resolves with its data too.
326
+ */
327
+ export type RouteResult<Out> =
328
+ Out extends Effect.Effect<infer A, infer _E, infer _R> ? A : Awaited<Out>;
329
+
330
+ /**
331
+ * Turns route functions into the promise-returning functions React Router
332
+ * calls. An Effect result runs through the factory's error model with the
333
+ * factory's runtime and the request's effect context; any other result passes
334
+ * through. The server adapter applies these to every route module, and tests
335
+ * can call them directly.
336
+ *
337
+ * Each is generic over the route's own args, so a test can pass a route's
338
+ * `Route.LoaderArgs` without a cast. React Router always supplies `request`
339
+ * and `context`.
340
+ */
341
+ export interface RouteHandlers {
342
+ readonly loader: <Args extends RouteArgs, Out>(
343
+ fn: (args: Args) => Out,
344
+ ) => (args: Args) => Promise<RouteResult<Out>>;
345
+ readonly action: <Args extends RouteArgs, Out>(
346
+ fn: (args: Args) => Out,
347
+ ) => (args: Args) => Promise<RouteResult<Out>>;
348
+ /**
349
+ * The wrapped middleware takes React Router's args, without `next`: the
350
+ * runner adds `next` as an effect.
351
+ */
352
+ readonly middleware: <Args extends RouteArgs, Next extends () => Promise<unknown>, Out>(
353
+ fn: (args: Args, next: Next) => Out,
354
+ ) => (args: Omit<Args, "next">, next: Next) => Promise<RouteResult<Out> | undefined>;
355
+ /** The router context key that holds the request's effect context. */
356
+ readonly requestContext: RouterContext<Context.Context<never>>;
357
+ }
358
+
359
+ /**
360
+ * The types a factory exposes for the server adapter's route check and for
361
+ * `Register`. Never present at runtime.
362
+ */
363
+ export interface FactoryTypes<DomainError, Returns, Services, Meta> {
364
+ readonly DomainError: DomainError;
365
+ readonly Returns: Returns;
366
+ readonly Services: Services;
367
+ readonly Meta: Meta;
368
+ }
369
+
269
370
  // ---------------------------------------------------------------------------
270
371
  // Factory.
271
372
  // ---------------------------------------------------------------------------
@@ -277,7 +378,7 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
277
378
  * Declare the app's **domain errors** as the type argument, then register an
278
379
  * *optional* handler per domain error in `errorHandlers`. The handler keys
279
380
  * autocomplete to your domain-error tags and each `error` parameter is typed from
280
- * its key — no annotation needed. It's curried so you can pin the domain errors
381
+ * its key - no annotation needed. It's curried so you can pin the domain errors
281
382
  * while the handler return types are still inferred:
282
383
  *
283
384
  * ```ts
@@ -293,10 +394,10 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
293
394
  * }));
294
395
  * ```
295
396
  *
296
- * The inner config is a **builder** — it receives the library's `Respond` helpers so
397
+ * The inner config is a **builder** - it receives the library's `Respond` helpers so
297
398
  * your handlers can recover/throw with them, and returns the config. `Respond` is
298
399
  * also returned from the factory (extended with any `respond` helpers you add), so
299
- * your app imports a *single* `Respond` — no auto-import ambiguity with a library one.
400
+ * your app imports a *single* `Respond` - no auto-import ambiguity with a library one.
300
401
  *
301
402
  * A handler *remaps* the error by returning either a library route error
302
403
  * (`Respond.early` to recover; `Respond.throw` / `Respond.redirect` to throw) or an
@@ -305,13 +406,13 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
305
406
  *
306
407
  * **Declared domain errors** may be left unhandled: if one implements
307
408
  * `HttpServerRespondable` it's rendered automatically, otherwise it falls through
308
- * to a 500. **Any other error** — a service-specific error the route consumes that
309
- * isn't a declared domain error — *must* be handled in the loader/action, or
409
+ * to a 500. **Any other error** - a service-specific error the route consumes that
410
+ * isn't a declared domain error - *must* be handled in the loader/action, or
310
411
  * `makeLoader`/`makeAction` fails to type-check.
311
412
  *
312
413
  * Pass a `runtime` (a `ManagedRuntime`, e.g. from `ManagedRuntime.make(AppLayer)`)
313
414
  * to provide your app's services once. Loader/action effects may then require those
314
- * services directly — no per-call `Effect.provide`:
415
+ * services directly - no per-call `Effect.provide`:
315
416
  *
316
417
  * ```ts
317
418
  * const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
@@ -333,9 +434,9 @@ export type RequestContextKey<ReqServices> = RouterContext<Context.Context<ReqSe
333
434
  * generated `Route.MiddlewareFunction` (a type argument, since middleware has no
334
435
  * separate args-only type) to type `args` and `next`'s result from that route; it
335
436
  * defaults to React Router's own `MiddlewareFunction`. The `next` handed to the
336
- * effect is `() => Effect.Effect<Result, FailureResponse>` — call it with `yield*`
437
+ * effect is `() => Effect.Effect<Result, FailureResponse>` - call it with `yield*`
337
438
  * to run the rest of the chain, and a downstream throw (e.g. a nested loader's
338
- * redirect) surfaces as a typed failure instead of a rejected promise:
439
+ * redirect) becomes a typed failure instead of a rejected promise:
339
440
  *
340
441
  * ```ts
341
442
  * const middleware = makeMiddleware<Route.MiddlewareFunction>((args, next) =>
@@ -353,6 +454,7 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
353
454
  RServices = never,
354
455
  ReqServices = never,
355
456
  const ExtraRespond extends Record<string, (...args: never[]) => unknown> = {},
457
+ Meta = never,
356
458
  >(
357
459
  /**
358
460
  * Builds the factory config. It receives the library's base `Respond` helpers
@@ -363,7 +465,7 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
363
465
  /**
364
466
  * An optional handler per declared domain error, keyed by its tag. The keys
365
467
  * autocomplete to your domain-error tags and each handler's `error` parameter
366
- * is typed automatically — no annotation needed. A handler *remaps* the error
468
+ * is typed automatically - no annotation needed. A handler *remaps* the error
367
469
  * by returning a library route error (the builder's `Respond.early` / `throw`
368
470
  * / `redirect`) or an `Effect` (`Effect.succeed`/`fail`). Omit to register none.
369
471
  */
@@ -384,18 +486,29 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
384
486
  */
385
487
  requestContext?: RequestContextKey<ReqServices>;
386
488
  /**
387
- * App-specific `Respond` helpers — merged onto the base `Respond` that the
489
+ * App-specific `Respond` helpers - merged onto the base `Respond` that the
388
490
  * factory returns, so your app imports one `Respond`. Typically opinionated
389
491
  * error constructors; annotate their parameters, e.g.
390
492
  * `{ formError: (reply: string) => new FormError({ reply }) }`. The base
391
493
  * helpers (`early` / `throw` / `redirect`) always win the merge.
392
494
  */
393
495
  respond?: ExtraRespond;
496
+ /**
497
+ * Request-scoped metadata to attach to every action's data under `__meta`,
498
+ * read after the action resolves. The server adapter attaches it to every
499
+ * action it runs, including plain functions and `makeAction` wrappers. A
500
+ * thrown result gets none. Only plain records and `data(...)` bodies include
501
+ * it; other values are returned unchanged.
502
+ */
503
+ actionMeta?: (args: ActionMetaArgs) => Meta;
394
504
  },
395
505
  ) {
396
506
  const config = builder(baseRespond);
397
507
  const runtime = config.runtime;
398
508
  const requestContextKey = config.requestContext;
509
+ // Where the request's effect context is stored: the app's key, or the library's own.
510
+ const storeKey: RequestContextKey<ReqServices> =
511
+ requestContextKey ?? (defaultRequestContext as RequestContextKey<ReqServices>);
399
512
  // The single `Respond` an app uses: base helpers plus any app-specific ones.
400
513
  // Base wins the merge so core helpers can't be shadowed.
401
514
  const Respond = { ...config.respond, ...baseRespond } as BaseRespond & ExtraRespond;
@@ -404,18 +517,17 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
404
517
  // Defaults to an empty map when `errorHandlers` is omitted.
405
518
  const userHandlers = (config.errorHandlers ?? {}) as unknown as Record<
406
519
  string,
407
- ErrorHandler<DomainError>
520
+ ErrorHandler<Tagged>
408
521
  >;
409
-
410
- const isUserError = (e: unknown): e is DomainError =>
411
- typeof e === "object" && e !== null && "_tag" in e && (e as Tagged)._tag in userHandlers;
522
+ /** The tags of the registered domain errors. */
523
+ const userTags = Object.keys(userHandlers);
412
524
 
413
525
  /**
414
526
  * Runs a handler's effect to completion: dispatches its whole error channel
415
527
  * (registered domain errors, library route errors, respondables, or the 500
416
528
  * default), provides the per-request context, and executes against the
417
529
  * configured runtime. Shared by `makeLoader`/`makeAction` and `makeMiddleware`
418
- * — they differ only in how they build the effect and what `context` they read.
530
+ * - they differ only in how they build the effect and what `context` they read.
419
531
  */
420
532
  function runProgram<E>(
421
533
  effect: Effect.Effect<unknown, E, RServices | ReqServices>,
@@ -424,10 +536,10 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
424
536
  ): Promise<unknown> {
425
537
  // The internal channel is deliberately loose (`unknown` success); the caller
426
538
  // restores the precise resolved type (computed from `E` and the handler map).
427
- // Sound at runtime — the values produced are exactly those types.
539
+ // Sound at runtime - the values produced are exactly those types.
428
540
  const program = effect.pipe(
429
- // Catch the whole error channel and dispatch. The refinement is `e is E`
430
- // (provably ⊆ E, and a *refinement* not a bare predicate — the predicate
541
+ // Catch the full error channel and dispatch. The refinement is `e is E`
542
+ // (provably ⊆ E, and a *refinement* not a bare predicate - the predicate
431
543
  // overload crashes tsc over a generic `E`). A declared domain error with no
432
544
  // handler (and not respondable) falls through to the 500 default.
433
545
  Effect.catchIf(
@@ -435,9 +547,11 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
435
547
  (e): Effect.Effect<unknown, FailureResponse> => {
436
548
  // Registered domain error → remap. A library-error return is processed
437
549
  // by the internal dispatch; an `Effect` return is used as-is.
438
- if (isUserError(e)) {
439
- const out = userHandlers[e._tag](e);
440
- return isRouteError(out) ? processRouteError(out) : out;
550
+ for (const tag of userTags) {
551
+ if (Predicate.isTagged(e, tag)) {
552
+ const out = userHandlers[tag](e);
553
+ return isRouteError(out) ? processRouteError(out) : out;
554
+ }
441
555
  }
442
556
  // A library route error raised directly in the handler → recover/throw.
443
557
  if (isRouteError(e)) return processRouteError(e);
@@ -456,12 +570,16 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
456
570
  },
457
571
  ),
458
572
  );
459
- // Provide the per-request context (set by middleware) so `ReqServices` are
460
- // satisfied, leaving only the runtime's `RServices` in the requirements.
461
- // The cast is sound: `provideContext` removes `ReqServices`, and when no
462
- // `requestContext` is configured `ReqServices` is `never` (nothing removed).
573
+ // Provide the request's effect context so `ReqServices` are satisfied,
574
+ // leaving only the runtime's `RServices` in the requirements. Under the
575
+ // server adapter the store also holds the HTTP request fiber's context, and
576
+ // whatever an effect middleware provided around `next()`. The cast is sound:
577
+ // `provideContext` removes `ReqServices`, and when no `requestContext` is
578
+ // configured `ReqServices` is `never` (nothing removed).
579
+ const store = readRequestStore(context);
580
+ // oxlint-disable-next-line effecttsgo/unsafe-effect-type-assertion -- the cast is sound, as the comment above explains
463
581
  const provided = (
464
- requestContextKey ? Effect.provideContext(program, context.get(requestContextKey)) : program
582
+ store === undefined ? program : Effect.provideContext(program, store)
465
583
  ) as Effect.Effect<unknown, FailureResponse, RServices>;
466
584
  // Run against the configured runtime so its services satisfy the effect's
467
585
  // `R`; with no runtime, the effect requires nothing and runs standalone.
@@ -470,15 +588,16 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
470
588
  // signal and discards its result, so the interrupt rejection is never rendered.
471
589
  return runtime
472
590
  ? runtime.runPromise(provided, { signal })
473
- : Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>, { signal });
591
+ : // oxlint-disable-next-line effecttsgo/unsafe-effect-type-assertion -- with no runtime `RServices` is `never`
592
+ Effect.runPromise(provided as Effect.Effect<unknown, FailureResponse>, { signal });
474
593
  }
475
594
 
476
595
  function makeLoaderOrAction<Args extends LoaderFunctionArgs | ActionFunctionArgs, A, E, R>(
477
- // `A`/`E`/`R` infer directly from the effect (naked, so inference stays robust).
596
+ // `A`/`E`/`R` infer directly from the effect (naked, so inference stays intact).
478
597
  // The diagnostic is intersected onto the effect's return type: when the effect
479
598
  // requires a service the factory doesn't provide (`R` ⊄ runtime+requestContext),
480
599
  // or can fail with something the library won't handle, `Diagnose` becomes a
481
- // message object the effect isn't assignable to — so this argument fails to
600
+ // message object the effect isn't assignable to - so this argument fails to
482
601
  // type-check with a readable explanation, and the offending types, right here.
483
602
  fn: (
484
603
  args: Args,
@@ -501,30 +620,30 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
501
620
  * args-only type) to type `args`, `next` and the result from that route; it defaults
502
621
  * to React Router's own `MiddlewareFunction`.
503
622
  *
504
- * The errors the library deals with for a loader/action it deals with here too —
505
- * declared domain errors, library route errors and respondables — plus the
623
+ * The errors the library deals with for a loader/action it deals with here too -
624
+ * declared domain errors, library route errors and respondables - plus the
506
625
  * `FailureResponse` `next()` fails with. Anything else must be handled in the effect,
507
626
  * as must any service neither the `runtime` nor the `requestContext` provides.
508
627
  *
509
- * The wrapper is typed as the route's own middleware — `(args, next) =>
510
- * Promise<Result | undefined>` — so it drops straight into a
628
+ * The wrapper is typed as the route's own middleware - `(args, next) =>
629
+ * Promise<Result | undefined>` - so it drops straight into a
511
630
  * `Route.MiddlewareFunction[]`. A handler that recovers (`Respond.early`) resolves
512
631
  * with `data(...)`, which react-router's server middleware pipeline turns into the
513
632
  * short-circuit `Response`.
514
633
  */
515
634
  function makeMiddleware<
516
635
  Middleware extends AnyMiddlewareFunction = MiddlewareFunction,
517
- // A pinned call passes an explicit type argument, which makes the whole call
518
- // explicit — TypeScript has no partial inference — so these defaults stand in
636
+ // A pinned call passes an explicit type argument, which makes every type argument of the call
637
+ // explicit - TypeScript has no partial inference - so these defaults stand in
519
638
  // for what would otherwise be inferred: exactly the errors the library handles
520
639
  // and the services the factory provides.
521
640
  E = LibraryHandled<DomainError, FailureResponse>,
522
641
  R = RServices | ReqServices,
523
642
  >(
524
643
  // Same shape as `makeLoaderOrAction`'s `fn`, but middleware also receives
525
- // `next` — wrapped as an effect (rather than react-router's raw
644
+ // `next` - wrapped as an effect (rather than react-router's raw
526
645
  // `() => Promise<Result>`) so downstream failures (e.g. a nested loader's
527
- // redirect) surface as a typed `FailureResponse`, consistent with the rest
646
+ // redirect) become a typed `FailureResponse`, consistent with the rest
528
647
  // of the library's error model. The success type is fixed to the route's own
529
648
  // `Result` (plus `void`), which is what react-router accepts back.
530
649
  fn: (
@@ -538,33 +657,124 @@ export function makeEffectRouteFactory<DomainError extends Tagged = never>() {
538
657
  ) => Promise<MiddlewareResultOf<Middleware> | undefined> {
539
658
  // Inside the body the route's own arg/result types are opaque (they're
540
659
  // `Parameters<Middleware>[...]` of an unresolved type parameter), so dispatch
541
- // through the loose shape — as `makeLoaderOrAction` does with its diagnostic.
660
+ // through the loose shape - as `makeLoaderOrAction` does with its diagnostic.
542
661
  // Sound at runtime: the values produced are exactly the declared types.
543
662
  const run = fn as unknown as (
544
663
  args: MiddlewareArgsOf<Middleware>,
545
664
  next: () => Effect.Effect<unknown, FailureResponse>,
546
665
  ) => Effect.Effect<unknown, E, RServices | ReqServices>;
547
666
  return (args, next) => {
548
- const effectNext = () =>
549
- Effect.tryPromise({
550
- try: next as unknown as () => Promise<unknown>,
551
- catch: (e) => e as FailureResponse,
552
- });
553
- const { context, request } = args as unknown as {
554
- context: Readonly<RouterContextProvider>;
555
- request: Request;
556
- };
667
+ const { context, request } = args as unknown as RouteArgs;
668
+ const effectNext = makeEffectNext(context, next as unknown as () => Promise<unknown>);
557
669
  return runProgram(run(args, effectNext), context, request.signal) as Promise<
558
670
  MiddlewareResultOf<Middleware> | undefined
559
671
  >;
560
672
  };
561
673
  }
562
674
 
675
+ /**
676
+ * Reads the request's effect context from the router context. A test that
677
+ * passes args without a router context gets none, as before.
678
+ */
679
+ function readRequestStore(
680
+ context: Readonly<RouterContextProvider> | undefined,
681
+ ): Context.Context<ReqServices> | undefined {
682
+ // An app key without a default that no middleware has set throws here, as
683
+ // it always has: the app promised its services would be there. The
684
+ // library's own key starts empty.
685
+ return typeof context?.get === "function" ? context.get(storeKey) : undefined;
686
+ }
687
+
688
+ /**
689
+ * Wraps React Router's `next` as an effect. Before it runs the rest of the
690
+ * chain, it adds to the request's effect context what the calling fiber
691
+ * provides beyond the context the middleware started with: services it
692
+ * provides around `next()`, log annotations, and its current span. Updates
693
+ * the middleware made with `context.set` stay. A downstream rejection is
694
+ * already a finished response, so it fails with that response as-is.
695
+ */
696
+ function makeEffectNext(
697
+ context: Readonly<RouterContextProvider>,
698
+ next: () => Promise<unknown>,
699
+ ): () => Effect.Effect<unknown, FailureResponse> {
700
+ const started = readRequestStore(context);
701
+ return () =>
702
+ Effect.withFiber((fiber) => {
703
+ if (started !== undefined) {
704
+ context.set(
705
+ storeKey,
706
+ Context.merge(context.get(storeKey), providedSince(started, fiber.context)),
707
+ );
708
+ }
709
+ return Effect.tryPromise({ try: next, catch: (e) => e as FailureResponse });
710
+ });
711
+ }
712
+
713
+ /** Runs a loader or action's result: an Effect through the error model, anything else as-is. */
714
+ function runResult(result: unknown, args: RouteArgs): unknown {
715
+ return Effect.isEffect(result)
716
+ ? runProgram(
717
+ // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context, effecttsgo/unsafe-effect-type-assertion -- a route function's Effect is only known to be an Effect here; `runProgram` dispatches its whole error channel
718
+ result as Effect.Effect<unknown, unknown, RServices | ReqServices>,
719
+ args.context,
720
+ args.request.signal,
721
+ )
722
+ : result;
723
+ }
724
+
725
+ const actionMeta = config.actionMeta;
726
+
727
+ // React Router calls every route function with args that include at least
728
+ // `request` and `context`, so each wrapper reads them through `RouteArgs`.
729
+ // The runners resolve with what `runResult` produces: the Effect's success
730
+ // or recovered data, or the awaited plain result. `RouteResult` is its type.
731
+ const handlers: RouteHandlers = {
732
+ loader: (fn) => async (args) =>
733
+ runResult(fn(args), args) as RouteResult<ReturnType<typeof fn>>,
734
+ action: (fn) => async (args) => {
735
+ const result = await runResult(fn(args), args);
736
+ return (
737
+ actionMeta === undefined ? result : attachActionMeta(result, actionMeta(args))
738
+ ) as RouteResult<ReturnType<typeof fn>>;
739
+ },
740
+ middleware: (fn) => async (args, next) => {
741
+ const routeArgs: RouteArgs = args;
742
+ // The runner supplies `next` as an effect in place of the one `Args`
743
+ // names, which it cannot prove for an arbitrary `Args`.
744
+ const effectArgs = {
745
+ ...args,
746
+ next: makeEffectNext(routeArgs.context, next),
747
+ } as unknown as Parameters<typeof fn>[0];
748
+ return runResult(fn(effectArgs, next), routeArgs) as RouteResult<ReturnType<typeof fn>>;
749
+ },
750
+ requestContext: storeKey as RouterContext<Context.Context<never>>,
751
+ };
752
+
563
753
  return {
564
754
  makeLoader: makeLoaderOrAction,
565
- makeAction: makeLoaderOrAction,
755
+ makeAction: makeLoaderOrAction as <
756
+ Args extends LoaderFunctionArgs | ActionFunctionArgs,
757
+ A,
758
+ E,
759
+ R,
760
+ >(
761
+ fn: (
762
+ args: Args,
763
+ ) => Effect.Effect<A, E, R> & Diagnose<E, R, DomainError, RServices | ReqServices>,
764
+ ) => (
765
+ args: Args,
766
+ ) => Promise<WithActionMeta<A | DirectRecover<E> | RecoverOf<DomainError, Returns, E>, Meta>>,
566
767
  makeMiddleware,
567
768
  Respond,
769
+ /** The route runner the server adapter applies to every route module. */
770
+ handlers,
771
+ /** Type-only: what the adapter's route check and `Register` read. */
772
+ "~types": undefined as unknown as FactoryTypes<
773
+ DomainError,
774
+ Returns,
775
+ RServices | ReqServices,
776
+ Meta
777
+ >,
568
778
  };
569
779
  };
570
780
  }