react-router-effect 2.0.0 → 2.1.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
@@ -1,169 +1,213 @@
1
1
  # react-router-effect
2
2
 
3
- Wrap [React Router](https://reactrouter.com) framework-mode loaders, actions, and middleware with
4
- [Effect](https://effect.website), and get **typed, declarative error handling** for free.
3
+ Serve a [React Router](https://reactrouter.com) framework-mode app from [Effect](https://effect.website)'s
4
+ Node HTTP server, and write its loaders, actions and middleware as Effects.
5
5
 
6
- Write your loader/action/middleware as an `Effect`. When it short-circuits with a tagged error,
7
- the library decides — based on handlers you register once — whether to **recover** (return data
8
- the component reads via `useLoaderData`) or **throw** (send it to the error boundary or issue
9
- a redirect). The resolved type of every loader/action reflects exactly what it can return.
6
+ A route function that returns an `Effect` runs with your app's services. When it fails with a tagged
7
+ error, the route factory you configure once decides what happens: recover with data for
8
+ `useLoaderData`, send the error to the error boundary, or redirect. The type of the route's data
9
+ lists what the route can return.
10
10
 
11
11
  ```ts
12
- const loader = makeLoader((args: Route.LoaderArgs) =>
13
- Effect.gen(function* () {
14
- const user = yield* getUser(args); // may fail with your domain errors
15
- if (!user.onboarded) {
16
- yield* Respond.redirect("/onboarding"); // throw → redirect
17
- }
18
- return { user };
19
- }),
20
- );
21
- // loader: (args) => Promise<{ user: User } /* | recovered shapes */>
12
+ export const loader = Effect.fn("loader.profile")(function* ({
13
+ params,
14
+ }: EffectRouteArgs<Route.LoaderArgs>) {
15
+ const profile = yield* (yield* Profiles).get(params.id); // fails with your domain errors
16
+ if (!profile.public) yield* Respond.early({ restricted: true }); // recover with typed data
17
+ return { profile };
18
+ });
19
+ // Route.ComponentProps["loaderData"]: { profile: Profile } | { restricted: boolean }
22
20
  ```
23
21
 
22
+ If your app does not serve React Router from an Effect HTTP server, read
23
+ [Without the Effect HTTP server](#without-the-effect-http-server).
24
+
24
25
  ## Install
25
26
 
26
27
  ```bash
27
- vp add react-router-effect effect react-router
28
+ vp add react-router-effect effect @effect/platform-node
28
29
  ```
29
30
 
30
- `effect` and `react-router` are peer dependencies. The library requires Effect 4 (`effect@^4.0.0`).
31
+ `effect`, `@effect/platform-node` and `react-router` are peer dependencies. The library requires
32
+ Effect 4.1 (`effect@^4.1.0`). Only `react-router-effect/vite` imports `@effect/platform-node`.
31
33
 
32
- ## Concepts
34
+ ## Get started
33
35
 
34
- A loader/action effect can short-circuit in three ways, via the `Respond` helpers:
36
+ [`examples/basic`](examples/basic) is an app with these files. It also has development with
37
+ Vite, tests against the production build, and benchmarks.
35
38
 
36
- | Helper | Outcome | Where it lands |
37
- | ------------------------------ | ----------- | --------------------------------------------------- |
38
- | `Respond.early(value, init?)` | **recover** | resolves with `data(value, init)` → `useLoaderData` |
39
- | `Respond.throw(value, init?)` | **throw** | rejects with `data(value, init)` → error boundary |
40
- | `Respond.redirect(url, init?)` | **throw** | rejects with a redirect `Response` |
39
+ ### 1. Create the route factory
41
40
 
42
- You declare your app's **domain errors** as a type argument to the factory, and may register a
43
- handler per domain error. A handler _remaps_ an error by returning either:
41
+ The route factory has what every route Effect needs: the runtime that provides your services, and
42
+ the handlers for your domain errors.
44
43
 
45
- - a library route error — `Respond.early(...)` / `Respond.throw(...)` / `Respond.redirect(...)`; or
46
- - an `Effect` — `Effect.succeed(value)` to **recover** with `value`, or `Effect.fail(response)`
47
- to **throw** a `Response` / `DataWithResponseInit`.
44
+ ```ts
45
+ // app/lib/route-factory.server.ts
46
+ import { ManagedRuntime } from "effect";
47
+ import { makeEffectRouteFactory } from "react-router-effect";
48
48
 
49
- A **declared domain error** may be left unhandled:
49
+ import { InvalidTodo, Todos } from "./services.server";
50
50
 
51
- - if it implements [`HttpServerRespondable`](https://effect.website) it's rendered automatically
52
- from its own response;
53
- - otherwise it falls through to a **500**.
51
+ export const runtime = ManagedRuntime.make(Todos.layer);
54
52
 
55
- **Any other error** a route consumes — a service-specific error that isn't a declared domain
56
- error — **must be handled** in the loader/action (caught or mapped), or `makeLoader`/`makeAction`
57
- fails to type-check with a message naming the unhandled error(s). This gives app-wide defaults for
58
- declared errors while enforcing explicit handling of feature/service-specific ones. (Likewise, an
59
- effect that requires a service neither the `runtime` nor the `requestContext` provides fails to
60
- type-check with a message naming the missing service.)
53
+ // Pin the domain errors, then the handler types are inferred.
54
+ export const routeFactory = makeEffectRouteFactory<InvalidTodo>()((Respond) => ({
55
+ runtime,
56
+ errorHandlers: {
57
+ // A form error goes back to the form as data, with a 422.
58
+ InvalidTodo: (error) => Respond.early({ error: error.message }, 422),
59
+ },
60
+ }));
61
61
 
62
- ## Usage
62
+ // Routes import this `Respond`. No library export has the same name.
63
+ export const { Respond } = routeFactory;
64
+ ```
63
65
 
64
- ### 1. Configure the factory once
66
+ ### 2. Create the server app
65
67
 
66
- ```ts
67
- // app/route.server.ts
68
- import { Data, Effect } from "effect";
69
- import { makeEffectRouteFactory } from "react-router-effect";
68
+ `ReactRouterHttp.make` returns an Effect that makes an Effect HTTP app. The app runs React Router
69
+ with your route factory.
70
70
 
71
- class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
72
- class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
73
- class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
71
+ ```ts
72
+ // server/app.ts
73
+ import { ReactRouterHttp } from "react-router-effect/server";
74
74
 
75
- // Declare every error your app handles app-wide. `DbError` has no handler below,
76
- // so it falls through to the 500 default.
77
- type DomainErrors = FormError | BadInputError | DbError;
75
+ import { routeFactory } from "../app/lib/route-factory.server";
78
76
 
79
- // Curried: pin the domain errors, then the handler types are inferred. The config
80
- // is a *builder* — it receives `Respond` (the library's `early`/`throw`/`redirect`)
81
- // so your handlers can recover/throw, and the factory hands back a single `Respond`
82
- // for your routes to import (no auto-import ambiguity with a library export).
83
- export const { makeLoader, makeAction, Respond } = makeEffectRouteFactory<DomainErrors>()(
84
- (Respond) => ({
85
- // App-specific helpers — merged onto the returned `Respond` (base helpers win):
86
- respond: {
87
- formError: (reply: SubmissionResponse) => new FormError({ reply }),
88
- },
89
- errorHandlers: {
90
- // keys autocomplete to your domain-error tags; `error` is typed from its key.
91
- // recover: short-circuit and hand the reply to the component
92
- FormError: (error) => Respond.early({ reply: error.reply }),
93
- // throw: send to the error boundary
94
- BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
95
- },
96
- }),
97
- );
77
+ export const reactRouterApp = ReactRouterHttp.make({
78
+ build: () => import("virtual:react-router/server-build"),
79
+ factory: routeFactory,
80
+ clientAssets: true, // serves build/client in production
81
+ });
98
82
  ```
99
83
 
100
- > **One `Respond`, no annotations.** Your routes import the `Respond` returned here — there's
101
- > no library-level `Respond` to clash with it on auto-import. Handler keys autocomplete to your
102
- > declared domain-error tags, each `error` parameter is typed from its key, and the precise
103
- > recover types are derived from each handler's return.
84
+ ### 3. Serve it
104
85
 
105
- ### 2. Write loaders/actions as effects
86
+ The server makes the app once, when it starts, and serves it with its own Effect HTTP server and its
87
+ own middleware. In production, making the app loads the build and reads the client build. If the
88
+ server cannot read the client build, it fails to start, and the error names the resolved directory.
106
89
 
107
90
  ```ts
108
- // app/routes/profile.ts
109
- import { Effect } from "effect";
110
- import { makeLoader, Respond } from "../route.server.ts";
111
- import type { Route } from "./+types/profile";
91
+ // server/main.ts
92
+ import { createServer } from "node:http";
112
93
 
113
- const loaderEffect = ({ params }: Route.LoaderArgs) =>
114
- Effect.gen(function* () {
115
- const profile = yield* getProfile(params.id); // may fail with FormError / BadInputError
116
- if (!profile.public) {
117
- yield* Respond.early({ restricted: true }); // recover with typed data
118
- }
119
- return { profile };
120
- });
94
+ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
95
+ import { Effect, Layer } from "effect";
96
+ import { HttpMiddleware, HttpServer } from "effect/http";
97
+
98
+ import { reactRouterApp } from "./app";
121
99
 
122
- export const loader = makeLoader((args: Route.LoaderArgs) => loaderEffect(args));
100
+ Layer.unwrap(
101
+ Effect.map(reactRouterApp, (app) => HttpServer.serve(app, HttpMiddleware.logger)),
102
+ ).pipe(
103
+ Layer.provide(NodeHttpServer.layer(() => createServer(), { port: 3000 })),
104
+ Layer.launch,
105
+ NodeRuntime.runMain,
106
+ );
123
107
  ```
124
108
 
125
- The resolved type is computed from the effect and your handlers:
109
+ ### 4. Build the server
110
+
111
+ Make `server/main.ts` the entry of the server build. The build then contains your server, and
112
+ `react-router build` writes it to `build/server/index.js`.
126
113
 
127
114
  ```ts
128
- type LoaderData = Route.ComponentProps["loaderData"];
129
- // { profile: Profile } | DataWithResponseInit<{ restricted: boolean }>
130
- // ( BadInputError throws, so it never appears here )
115
+ // vite.config.ts
116
+ import { reactRouter } from "@react-router/dev/vite";
117
+ import { defineConfig } from "vite";
118
+
119
+ export default defineConfig({
120
+ plugins: [reactRouter()],
121
+ environments: { ssr: { build: { rollupOptions: { input: "./server/main.ts" } } } },
122
+ });
123
+ ```
124
+
125
+ ```bash
126
+ react-router build
127
+ NODE_ENV=production node build/server/index.js
131
128
  ```
132
129
 
133
- ### 3. Write middleware as effects
130
+ For development, read [Development with Vite](#development-with-vite).
131
+
132
+ ### 5. Write routes as Effects
134
133
 
135
- `makeMiddleware` wraps a [middleware](https://reactrouter.com/how-to/middleware) function the
136
- same way, wired to the same `errorHandlers`. Pin it to the route's generated
137
- `Route.MiddlewareFunction` — middleware has no separate args-only type, so the whole function
138
- type is the type argument — or omit it to default to React Router's own `MiddlewareFunction`:
134
+ A loader, action or middleware is an `Effect.fn`. It needs no wrapper. The adapter runs it with the
135
+ factory's runtime and error handlers.
139
136
 
140
137
  ```ts
141
- // app/routes/profile.ts
138
+ // app/routes/todos.tsx
142
139
  import { Effect } from "effect";
143
- import { makeMiddleware, Respond } from "../route.server.ts";
144
- import type { Route } from "./+types/profile";
140
+ import { data } from "react-router";
141
+ import type { EffectRouteArgs } from "react-router-effect";
145
142
 
146
- export const middleware: Route.MiddlewareFunction[] = [
147
- makeMiddleware<Route.MiddlewareFunction>(({ request }, next) =>
148
- Effect.gen(function* () {
149
- const user = yield* getUser(request); // may fail with FormError / BadInputError
150
- if (!user) yield* Respond.redirect("/login"); // throw → redirect, same as a loader
151
- return yield* next(); // run the rest of the chain
152
- }),
153
- ),
154
- ];
143
+ import type { Route } from "./+types/todos";
144
+
145
+ import { Todos } from "../lib/services.server";
146
+
147
+ export const loader = Effect.fn("loader.todos")(function* () {
148
+ return { todos: yield* (yield* Todos).list };
149
+ });
150
+
151
+ // `InvalidTodo` is not handled here. The route factory turns it into `{ error }` data with a 422.
152
+ export const action = Effect.fn("action.todos")(function* ({
153
+ request,
154
+ }: EffectRouteArgs<Route.ActionArgs>) {
155
+ const form = yield* Effect.promise(() => request.formData());
156
+ const todo = yield* (yield* Todos).add(String(form.get("title")));
157
+ return data({ todo }, { status: 201 });
158
+ });
155
159
  ```
156
160
 
157
- `next` is handed to the effect as `() => Effect.Effect<Result, FailureResponse>` (React Router's
158
- own `next: () => Promise<Result>`, wrapped) — call it with `yield*` to run downstream
159
- loaders/middleware. A downstream throw (e.g. a nested loader's redirect) surfaces as a typed
160
- failure rather than a rejected promise, so it flows through the same recover/throw handling as
161
- a loader or action.
161
+ `EffectRouteArgs` keeps hooks that take `typeof action` or `typeof loader` typed, such as
162
+ `useFetcher<typeof action>()`. [Plain Effect loaders, actions and
163
+ middleware](#plain-effect-loaders-actions-and-middleware) explains why. A route function that does
164
+ not return an Effect passes through to React Router unchanged, so promise loaders keep working in
165
+ the same app.
166
+
167
+ ## Handle errors
168
+
169
+ An Effect can short-circuit in three ways, with the `Respond` helpers:
162
170
 
163
- ### Self-rendering domain errors
171
+ | Helper | Outcome | Where it lands |
172
+ | ------------------------------ | ------- | --------------------------------------------------- |
173
+ | `Respond.early(value, init?)` | recover | resolves with `data(value, init)` → `useLoaderData` |
174
+ | `Respond.throw(value, init?)` | throw | rejects with `data(value, init)` → error boundary |
175
+ | `Respond.redirect(url, init?)` | throw | rejects with a redirect `Response` |
164
176
 
165
- If a domain error implements `HttpServerRespondable`, you don't need to register a handler —
166
- it renders its own response:
177
+ Declare your app's domain errors as the type argument of `makeEffectRouteFactory`. You can register
178
+ one handler for each. A handler remaps its error. It returns one of these:
179
+
180
+ - A library route error: `Respond.early(...)`, `Respond.throw(...)` or `Respond.redirect(...)`.
181
+ - An `Effect`: `Effect.succeed(value)` recovers with `value`, and `Effect.fail(response)` throws a
182
+ `Response` or `DataWithResponseInit`.
183
+
184
+ ```ts
185
+ import { Data, Effect } from "effect";
186
+
187
+ class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
188
+ class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
189
+ class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
190
+
191
+ // `DbError` has no handler below, so it falls through to the 500 default.
192
+ type DomainErrors = FormError | BadInputError | DbError;
193
+
194
+ export const routeFactory = makeEffectRouteFactory<DomainErrors>()((Respond) => ({
195
+ // App-specific helpers. The returned `Respond` has them next to the base helpers.
196
+ respond: {
197
+ formError: (reply: SubmissionResponse) => new FormError({ reply }),
198
+ },
199
+ errorHandlers: {
200
+ // Keys autocomplete to your domain-error tags, and `error` is typed from its key.
201
+ // Recover: pass the reply to the component.
202
+ FormError: (error) => Respond.early({ reply: error.reply }),
203
+ // Throw: send to the error boundary.
204
+ BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
205
+ },
206
+ }));
207
+ ```
208
+
209
+ A declared domain error can have no handler. If it implements `HttpServerRespondable`, it renders
210
+ its own response. A registered handler still takes precedence. Otherwise it falls through to a 500.
167
211
 
168
212
  ```ts
169
213
  import { HttpServerRespondable, HttpServerResponse } from "effect/http";
@@ -175,51 +219,384 @@ class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
175
219
  }
176
220
  ```
177
221
 
178
- A registered handler, if present, still takes precedence over the error's own response.
222
+ Any other error that a route consumes must be handled in the route. This includes a
223
+ service-specific error that is not a declared domain error. Catch it or map it. This gives the app
224
+ defaults for declared errors. The errors of one feature or service need explicit handling.
225
+ [The route check](#the-route-check) reports an unhandled error at compile time.
226
+
227
+ ## Provide services
179
228
 
180
- ### Providing services with a runtime
229
+ ### App-wide services with a runtime
181
230
 
182
- Pass a `runtime` (an effect [`ManagedRuntime`](https://effect.website)) and your loaders/actions
183
- may require its services directly — no per-call `Effect.provide`. The runtime is built once and
184
- reused for every request:
231
+ The `runtime` is an Effect [`ManagedRuntime`](https://effect.website). The factory builds it once and
232
+ reuses it for every request. Route Effects require its services directly, with no `Effect.provide`
233
+ in the route. The runtime's services are the allowed requirements of a route Effect. A route that
234
+ requires another service is a compile error. Without a `runtime`, a route Effect must require
235
+ nothing.
185
236
 
186
237
  ```ts
187
- // app/runtime.server.ts
188
- import { ManagedRuntime } from "effect";
189
- export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
238
+ export const loader = Effect.fn("loader.profile")(function* ({
239
+ params,
240
+ }: EffectRouteArgs<Route.LoaderArgs>) {
241
+ const svc = yield* MyService; // the runtime provides MyService
242
+ return { profile: yield* svc.load(params.id) };
243
+ });
244
+ ```
190
245
 
191
- // app/route.server.ts
192
- export const { makeLoader, makeAction } = makeEffectRouteFactory<DomainErrors>()(() => ({
193
- runtime: appRuntime,
194
- errorHandlers: { ... },
246
+ `errorHandlers` is optional. A factory can have a runtime only, or nothing:
247
+
248
+ ```ts
249
+ makeEffectRouteFactory()(() => ({ runtime }));
250
+ makeEffectRouteFactory()(() => ({}));
251
+ ```
252
+
253
+ ### Per-request services
254
+
255
+ The runtime provides app-wide services. A request-scoped service is the current user, a request id,
256
+ or a transaction for one request. There are two ways to provide one.
257
+
258
+ The adapter option `requestContext` builds the services before React Router runs. Every middleware,
259
+ loader and action sees them, and no middleware has to set them:
260
+
261
+ ```ts
262
+ ReactRouterHttp.make({
263
+ build: () => import("virtual:react-router/server-build"),
264
+ factory: routeFactory,
265
+ requestContext: (request) =>
266
+ Context.make(RequestInfo, {
267
+ id: request.headers.get("x-request-id") ?? crypto.randomUUID(),
268
+ startedAt: Date.now(),
269
+ }),
270
+ });
271
+ ```
272
+
273
+ A [middleware](https://reactrouter.com/how-to/middleware) can also provide services. An effect
274
+ middleware provides them around `next()`, and everything downstream of `next()` can require them:
275
+
276
+ ```ts
277
+ // app/root.tsx
278
+ export const middleware = [
279
+ Effect.fn("middleware.visitor")(function* ({
280
+ request,
281
+ next,
282
+ }: EffectMiddlewareArgs<Route.MiddlewareFunction>) {
283
+ const name = yield* readVisitor(request);
284
+ return yield* next().pipe(
285
+ Effect.provideService(Visitor, { name }),
286
+ Effect.annotateLogs({ visitor: name }),
287
+ );
288
+ }),
289
+ ];
290
+ ```
291
+
292
+ Tell the factory which services the request provides with a context key:
293
+
294
+ ```ts
295
+ // app/lib/route-factory.server.ts
296
+ import { createContext } from "react-router";
297
+ import type { RequestContextKey } from "react-router-effect";
298
+
299
+ export const requestContext: RequestContextKey<RequestInfo | Visitor> = createContext();
300
+
301
+ export const routeFactory = makeEffectRouteFactory<DomainErrors>()(() => ({
302
+ runtime,
303
+ requestContext,
195
304
  }));
305
+ ```
306
+
307
+ A route Effect can then require the runtime's services and the request's services. Any other
308
+ requirement is a compile error. `RequestContextKey<ReqServices>` is an alias of
309
+ `RouterContext<Context.Context<ReqServices>>`.
310
+
311
+ ## The adapter
312
+
313
+ `react-router-effect/server` is a React Router adapter for Effect's Node HTTP server
314
+ (`@effect/platform-node`). It handles everything between an `HttpServerRequest` and React Router. That
315
+ is the Web `Request` and its abort signal, the route factory, and the client build. It also converts
316
+ React Router's `Response`, with its streamed body and its cookies. The adapter is an
317
+ Effect HTTP app like any other. The app serves it with its own Effect HTTP server and keeps its own
318
+ middleware: logging, compression, tracing, proxies and other routes.
319
+
320
+ React Router keeps doing what it does for any other server adapter: documents, `.data` requests,
321
+ streamed promises, resource routes, the lazy route manifest and middleware. Before React Router
322
+ sees the build, the adapter runs every loader, action and middleware through your route factory:
323
+
324
+ - A route function that returns an Effect runs with the factory's runtime and request context.
325
+ Its failures go through the factory's error handlers.
326
+ - Any other route function passes through. Promise loaders and `makeLoader` wrappers keep working.
327
+
328
+ The options:
329
+
330
+ - `build` is the server build, or a function that loads it.
331
+ - `factory` is the route factory.
332
+ - `requestContext: (request) => Context` builds the request's services before React Router runs.
333
+ - `clientAssets: true` serves the client build, `build.assetsBuildDirectory` under
334
+ `build.publicPath`, before React Router. Files under `assets/` use a content hash in their name,
335
+ and the adapter caches them for a year as immutable. It caches other files for an hour. It serves
336
+ the files as `express.static` does. A directory serves its index file, such as a prerendered page.
337
+ A directory path without the trailing slash redirects to the path with it. It does not serve
338
+ dotfiles, and a request it does not serve goes to React Router. Like `express.static`, it looks up
339
+ each GET and HEAD request in the file system. It serves only a build that it loads once: in
340
+ production mode, or a build value. It never serves a development build, where Vite serves the
341
+ client. `assetsBuildDirectory` is relative to the working directory of the server. If the adapter
342
+ cannot read it, making the app fails with a `ClientBuildError` that names the resolved directory.
343
+ It needs the platform's file system, which `NodeHttpServer.layer` provides.
344
+ - `mode` is React Router's server mode. The default is `process.env.NODE_ENV`. In production the
345
+ adapter loads the build once.
346
+
347
+ The Web request comes from `HttpServerRequest.toWeb`, body included. Effect's stream sends the
348
+ `100 Continue` when React Router reads the body of a request that sends `Expect: 100-continue`.
349
+
350
+ The request URL is the one Effect derives from the request. An app behind a proxy applies the
351
+ headers it trusts in its own middleware, with `request.modify` or `HttpMiddleware.xForwardedHeaders`.
352
+ The request signal aborts only if the response ends early. That happens when the client leaves while
353
+ React Router runs, or while the body streams. A completed response never aborts it.
354
+
355
+ React Router logs and answers the errors of route code. If the adapter's own steps fail, for example
356
+ a `requestContext` that throws, the adapter logs the error with `Effect.logError` and answers 500.
357
+ The log goes to the loggers of the app's Effect runtime.
358
+
359
+ ### Development with Vite
360
+
361
+ In development Vite serves the client. `react-router-effect/vite` runs Vite's middleware in front of
362
+ the app, on Effect's Node HTTP server:
363
+
364
+ ```ts
365
+ import { ReactRouterVite } from "react-router-effect/vite";
366
+
367
+ const vite = await createServer({ server: { middlewareMode: true } });
368
+ HttpServer.serve(Effect.flatten(reactRouterApp).pipe(ReactRouterVite.devMiddleware(vite)));
369
+ ```
370
+
371
+ In development, making the app does nothing but return it, so the server can make it for each
372
+ request. Load the module that calls `ReactRouterHttp.make` through `vite.ssrLoadModule`. Then the route
373
+ modules, the factory and the adapter share Vite's module graph. The example's `server.js` and
374
+ `server/main.ts` do this.
375
+
376
+ ### Cookies
377
+
378
+ Set cookies from route code with React Router's cookie helpers, `createCookie` and the session
379
+ storages, as in any React Router app. The adapter converts React Router's `Set-Cookie` values into
380
+ the response's `cookies`, as `HttpServerResponse.fromWeb` does. Effect HTTP middleware around the
381
+ adapter can read and change them like any other cookie. A response that replaces React Router's has
382
+ none of them.
383
+
384
+ Effect keeps one cookie per name and only the attributes it knows: `Max-Age`, `Domain`, `Path`,
385
+ `Expires`, `HttpOnly`, `Secure`, `Partitioned`, `Priority` and `SameSite`. If one response sets the
386
+ same cookie name twice, for example to expire it on one path and set it on another, only the last
387
+ one is sent.
388
+
389
+ ### Content type
390
+
391
+ Effect gives a streamed body without a content type `application/octet-stream`. A resource route
392
+ that returns such a body sends that type, and the browser downloads the response. Examples are a
393
+ `ReadableStream`, bytes, or the body of another service's response. With Express, the response has
394
+ no content type, and the browser guesses one. Set `Content-Type` in the route:
395
+
396
+ ```ts
397
+ return new Response(Readable.toWeb(file) as ReadableStream, {
398
+ headers: { "content-type": "application/pdf" },
399
+ });
400
+ ```
401
+
402
+ ### Tracing
403
+
404
+ When the request has a server span, the adapter traces React Router. The span comes from
405
+ `HttpMiddleware.tracer` with a tracer that exports spans, such as `OtelTracer`. Each middleware,
406
+ loader and action runs in a span of its own, such as `react-router.loader routes/todos`. The spans
407
+ nest as React Router calls the functions. This is the example's `GET /todos`:
408
+
409
+ ```text
410
+ GET the server span, with http.route=/todos
411
+ └─ react-router.middleware root
412
+ └─ middleware.visitor the Effect middleware's own span, around next()
413
+ ├─ react-router.loader root
414
+ │ └─ loader.root the route Effect's span
415
+ └─ react-router.loader routes/todos
416
+ └─ loader.todos
417
+ ```
418
+
419
+ A middleware's span covers everything its `next()` runs: the next middleware, then the loaders and
420
+ the action. Route Effects nest under the span of their function. Effect's OpenTelemetry tracer makes
421
+ the current Effect span the active OpenTelemetry context. So the clients that an OpenTelemetry
422
+ instrumentation traces nest there too, such as a database driver that a promise loader calls.
423
+ Without a server span, or with Effect's native tracer, whose spans go nowhere, the adapter adds no
424
+ spans and no cost.
425
+
426
+ ## Route functions
427
+
428
+ ### Plain Effect loaders, actions and middleware
429
+
430
+ ```ts
431
+ export const loader = Effect.fn("loader.dashboard")(function* () {
432
+ const { organization } = yield* AuthContext.getScope;
433
+ return { organization };
434
+ });
435
+
436
+ export const action = Effect.fn("action.createTax")(function* ({
437
+ request,
438
+ }: EffectRouteArgs<Route.ActionArgs>) {
439
+ const submission = yield* parseSubmission(request);
440
+ return data({ reply: submission.reply() }, { status: 201 });
441
+ });
442
+
443
+ export const middleware = [
444
+ Effect.fn("middleware.adminOnly")(function* ({
445
+ next,
446
+ }: EffectMiddlewareArgs<Route.MiddlewareFunction>) {
447
+ const user = yield* AuthContext.getUser;
448
+ if (user.role !== "admin") return redirect("/settings");
449
+ return yield* next().pipe(Effect.annotateLogs({ userId: user.id }));
450
+ }),
451
+ ];
452
+ ```
453
+
454
+ - Route data: Effects are branded with React Router's `unstable_SerializesTo`. So
455
+ `Route.ComponentProps` types the data of an Effect loader or action, including what the factory
456
+ recovers.
457
+ - Hooks that take `typeof loader` or `typeof action`, such as `useFetcher<typeof action>()`,
458
+ read the data through React Router's `SerializeFrom`. It treats a function as a client
459
+ function when its argument has the members of client loader args, and gives client functions
460
+ their raw return value. Typegen's `Route.LoaderArgs` and `Route.ActionArgs` have those same
461
+ members, so for an Effect the hook would type the data as the Effect itself.
462
+ - Take the args through `EffectRouteArgs<Route.LoaderArgs>`. It makes `pattern` optional, so the
463
+ argument no longer matches and the hook reads the brand. A loader without args needs nothing.
464
+ - `RouteData<typeof loader>` reads the brand directly and applies React Router's serialization.
465
+ The brand records the data before serialization: it has to keep Effect's subtype order, and
466
+ React Router's `Serialize` does not.
467
+ - Middleware: an effect middleware gets `next` as an effect in its args. Services, log annotations
468
+ and the span that it provides around `next()` reach every route Effect downstream. `context.set`
469
+ keeps working. You cannot annotate a route array with an effect middleware as
470
+ `Route.MiddlewareFunction[]`.
471
+ - Action metadata: `actionMeta` in the factory config is read after every action resolves. The
472
+ adapter attaches it to the action's data under `__meta`.
473
+ - Tests: `routeFactory.handlers.loader(loader)(args)` runs a route function as the server does.
474
+
475
+ ### Register what the factory recovers
476
+
477
+ A plain Effect loader cannot see the factory's handlers. Declare what they recover once:
478
+
479
+ ```ts
480
+ declare module "react-router-effect" {
481
+ interface Register {
482
+ recover: { FormValidationError: { reply: SubmissionResponse } };
483
+ actionMeta: { realtimeEventIds: ReadonlyArray<string> };
484
+ }
485
+ }
486
+ ```
487
+
488
+ Declare the types directly. Reading them from `typeof routeFactory` is circular.
489
+
490
+ ### The route check
491
+
492
+ `RouteEffectCheck` reads the route modules that React Router's typegen registers. It fails to
493
+ compile in three cases:
494
+
495
+ - A route Effect can fail with an error that the factory does not handle.
496
+ - A route Effect needs a service that the runtime and the request context do not provide.
497
+ - `Register` disagrees with the factory.
498
+
499
+ ```ts
500
+ import type { RouteEffectCheck } from "react-router-effect/server";
501
+
502
+ export const routeEffects: RouteEffectCheck<typeof routeFactory> = {};
503
+ ```
504
+
505
+ ```text
506
+ Type '{}' is missing the following properties from type ...:
507
+ "routes/billing#loader must handle PlaidApiError",
508
+ "routes/admin#middleware needs a provider for app/Session"
509
+ ```
510
+
511
+ ### Requirements and limits
512
+
513
+ - Effect 4.1 or later, React Router 8.3 or later for the adapter, and `skipLibCheck`. The brand
514
+ augments `Effect`, and TypeScript cannot test the variance annotations of that augmentation.
515
+ - A route module export must be a function. A bare `Effect` value is not a loader.
516
+ - Prerendering (`prerender`) and RSC mode call the build without the adapter.
517
+
518
+ ## Without the Effect HTTP server
519
+
520
+ If your app serves React Router from Express, `@react-router/node`, a serverless platform or another
521
+ server, the adapter does not apply. The same route factory still returns `makeLoader`, `makeAction`
522
+ and `makeMiddleware`. Each wraps one route function. The wrapper runs the Effect with the factory's
523
+ runtime and error handlers, and returns a function that React Router calls.
524
+
525
+ ```ts
526
+ // app/routes/profile.ts
527
+ import { Effect } from "effect";
528
+ import { makeLoader, Respond } from "../route.server.ts";
529
+ import type { Route } from "./+types/profile";
196
530
 
197
- // app/routes/profile.ts — `MyService` is satisfied by the runtime, not provided here:
198
- const loader = makeLoader((args: Route.LoaderArgs) =>
531
+ export const loader = makeLoader((args: Route.LoaderArgs) =>
199
532
  Effect.gen(function* () {
200
- const svc = yield* MyService;
201
- return { profile: yield* svc.load(args.params.id) };
533
+ const profile = yield* getProfile(args.params.id); // can fail with FormError / BadInputError
534
+ if (!profile.public) {
535
+ yield* Respond.early({ restricted: true }); // recover with typed data
536
+ }
537
+ return { profile };
202
538
  }),
203
539
  );
204
540
  ```
205
541
 
206
- The runtime's services become the effect's allowed requirement channel: requiring a service the
207
- runtime provides type-checks, while requiring one it _doesn't_ is a compile error. With no
208
- `runtime`, effects must require nothing.
542
+ Take `makeLoader`, `makeAction`, `makeMiddleware` and `Respond` from the factory:
543
+
544
+ ```ts
545
+ // app/route.server.ts
546
+ export const { makeLoader, makeAction, makeMiddleware, Respond } =
547
+ makeEffectRouteFactory<DomainErrors>()((Respond) => ({
548
+ runtime: appRuntime,
549
+ errorHandlers: {/* ... */},
550
+ }));
551
+ ```
209
552
 
210
- `errorHandlers` is optional too — configure a factory with just a runtime, or with nothing:
553
+ `makeLoader` and `makeAction` are the same wrapper. The wrapper computes the resolved type from the
554
+ effect and your handlers:
211
555
 
212
556
  ```ts
213
- makeEffectRouteFactory()(() => ({ runtime }));
214
- makeEffectRouteFactory()(() => ({}));
557
+ type LoaderData = Route.ComponentProps["loaderData"];
558
+ // { profile: Profile } | DataWithResponseInit<{ restricted: boolean }>
559
+ // ( BadInputError throws, so it never appears here )
215
560
  ```
216
561
 
217
- ### Per-request services from middleware
562
+ Without the adapter, an unhandled error is a compile error at the `makeLoader` or `makeAction` call.
563
+ The message lists the unhandled errors. An effect can also require a service that neither the
564
+ `runtime` nor the `requestContext` provides. That fails the same way, and the message lists the
565
+ missing service.
566
+
567
+ ### Middleware
218
568
 
219
- The runtime provides app-wide services. For **request-scoped** services — the current user, a
220
- request id, a per-request transaction — use [middleware](https://reactrouter.com/how-to/middleware).
221
- Middleware sets a fresh effect `Context` on a React Router context key each request; the factory's
222
- `requestContext` reads it and provides those services to the loader/action:
569
+ `makeMiddleware` wraps a [middleware](https://reactrouter.com/how-to/middleware) function the same
570
+ way, with the same `errorHandlers`. Pin it to the route's generated `Route.MiddlewareFunction`.
571
+ Middleware has no separate args-only type, so the full function type is the type argument. If you
572
+ omit the type argument, it defaults to React Router's own `MiddlewareFunction`:
573
+
574
+ ```ts
575
+ import { Effect } from "effect";
576
+ import { makeMiddleware, Respond } from "../route.server.ts";
577
+ import type { Route } from "./+types/profile";
578
+
579
+ export const middleware: Route.MiddlewareFunction[] = [
580
+ makeMiddleware<Route.MiddlewareFunction>(({ request }, next) =>
581
+ Effect.gen(function* () {
582
+ const user = yield* getUser(request); // can fail with FormError / BadInputError
583
+ if (!user) yield* Respond.redirect("/login"); // throw → redirect, same as a loader
584
+ return yield* next(); // run the rest of the chain
585
+ }),
586
+ ),
587
+ ];
588
+ ```
589
+
590
+ The effect gets `next` as `() => Effect.Effect<Result, FailureResponse>`. It is React Router's own
591
+ `next: () => Promise<Result>`, wrapped. Call it with `yield*` to run the downstream loaders and
592
+ middleware. A downstream throw, such as a nested loader's redirect, becomes a typed failure instead
593
+ of a rejected promise. It flows through the same recover and throw handling as a loader or action.
594
+
595
+ ### Per-request services
596
+
597
+ Without the adapter there is no `requestContext` option. Set the context key in a plain React Router
598
+ middleware. The factory's `requestContext` reads it, and provides those services to the loader or
599
+ action:
223
600
 
224
601
  ```ts
225
602
  // app/request-context.server.ts
@@ -227,17 +604,13 @@ import { Context } from "effect";
227
604
  import { createContext } from "react-router";
228
605
  import type { RequestContextKey } from "react-router-effect";
229
606
 
230
- class RequestContext extends Context.Service<
231
- RequestContext,
232
- {
233
- readonly userId: string;
234
- }
235
- >()("app/RequestContext") {}
607
+ class RequestContext extends Context.Service<RequestContext, { readonly userId: string }>()(
608
+ "app/RequestContext",
609
+ ) {}
236
610
 
237
- // `requestContext` is a plain React Router context key — `createContext` is RR's own.
238
611
  export const requestContext: RequestContextKey<RequestContext> = createContext();
239
612
 
240
- // app/routes/profile.ts — middleware sets a fresh value per request:
613
+ // app/routes/profile.ts: a middleware sets a fresh value for each request.
241
614
  export const middleware: Route.MiddlewareFunction[] = [
242
615
  ({ context, request }, next) => {
243
616
  context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
@@ -245,13 +618,13 @@ export const middleware: Route.MiddlewareFunction[] = [
245
618
  },
246
619
  ];
247
620
 
248
- // app/route.server.ts — wire the same key into the factory:
621
+ // app/route.server.ts: wire the same key into the factory.
249
622
  export const { makeLoader } = makeEffectRouteFactory<DomainErrors>()(() => ({
250
623
  runtime: appRuntime,
251
624
  requestContext,
252
625
  }));
253
626
 
254
- // the loader requires RequestContext directly — no provide, fresh each request:
627
+ // The loader requires RequestContext directly, with a fresh value for each request.
255
628
  export const loader = makeLoader(() =>
256
629
  Effect.gen(function* () {
257
630
  const { userId } = yield* RequestContext;
@@ -260,26 +633,28 @@ export const loader = makeLoader(() =>
260
633
  );
261
634
  ```
262
635
 
263
- Effects may now require both the runtime's services and the request context's; requiring anything
264
- else is a compile error. `RequestContextKey<ReqServices>` is a type alias for
265
- `RouterContext<Context.Context<ReqServices>>` — sugar for annotating the key, nothing more.
266
-
267
636
  ## API
268
637
 
269
- - **`makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond? }))`**
270
- → `{ makeLoader, makeAction, makeMiddleware, Respond }` (`makeLoader`/`makeAction` are the same
271
- wrapper; `makeMiddleware` wraps a `Route.MiddlewareFunction`, defaulting to react-router's own
272
- `MiddlewareFunction`). The config is a _builder_ that receives the base `Respond`; all its fields
273
- are optional. The returned `Respond` is the base helpers merged with your `respond` extensions
274
- (base helpers win). A non-domain error left in a handler's error channel — or a required service
275
- that neither the `runtime` nor the `requestContext` provides — is a compile error.
276
- - **`RequestContextKey<ReqServices>`** — type of the React Router context key for a per-request
277
- effect context (`RouterContext<Context.Context<ReqServices>>`).
278
- - **`Respond`** (returned from the factory) — `early` (recover), `throw`, `redirect`, plus any
279
- `respond` helpers you add.
280
- - **`ReturnableDataError`**, **`ThrowableDataError`**, **`ThrowableRedirectError`** — the library
281
- route errors, and **`isRouteError`** to narrow them.
282
- - **`ErrorHandler<Err>`** — the handler signature type.
638
+ - `makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond?, actionMeta? }))`
639
+ returns `{ makeLoader, makeAction, makeMiddleware, Respond, handlers }`. The config is a builder
640
+ that receives the base `Respond`. All its fields are optional. The returned `Respond` is the base
641
+ helpers merged with your `respond` extensions, and the base helpers win. `makeLoader` and
642
+ `makeAction` are the same wrapper. `makeMiddleware` wraps a `Route.MiddlewareFunction`, and
643
+ defaults to React Router's own `MiddlewareFunction`.
644
+ - `handlers` (`loader`, `action`, `middleware`, `requestContext`) is the runner that the server
645
+ adapter applies to every route module.
646
+ - `Respond` has `early` (recover), `throw` and `redirect`, and the `respond` helpers that you add.
647
+ - `ReturnableDataError`, `ThrowableDataError` and `ThrowableRedirectError` are the library route
648
+ errors. `isRouteError` narrows them.
649
+ - `ErrorHandler<Err>` is the handler signature type.
650
+ - `RequestContextKey<ReqServices>` is the type of the React Router context key for a per-request
651
+ effect context.
652
+ - `EffectRouteArgs<Args>`, `EffectMiddlewareArgs<Middleware>`, `RouteData<F>` and `Register` are the
653
+ types of plain Effect routes.
654
+ - `react-router-effect/server` exports `ReactRouterHttp.make`, and the types `RouteEffectCheck` and
655
+ `RouteEffectFailures`.
656
+ - `react-router-effect/vite` exports `ReactRouterVite.devMiddleware`, for development. It needs
657
+ `@effect/platform-node`.
283
658
 
284
659
  ## Development
285
660