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 +548 -173
- package/dist/index.d.mts +2 -342
- package/dist/index.mjs +118 -30
- package/dist/rolldown-runtime-D8jQpDG7.mjs +12 -0
- package/dist/route-data-MgSnnR33.d.mts +543 -0
- package/dist/server.d.mts +218 -0
- package/dist/server.mjs +337 -0
- package/dist/vite.d.mts +27 -0
- package/dist/vite.mjs +46 -0
- package/package.json +19 -8
- package/src/action-meta.ts +58 -0
- package/src/check.ts +205 -0
- package/src/errors.ts +3 -3
- package/src/factory.ts +282 -82
- package/src/index.ts +22 -1
- package/src/route-data.ts +151 -0
- package/src/server-entry.ts +10 -0
- package/src/server.ts +647 -0
- package/src/vite-entry.ts +2 -0
- package/src/vite.ts +92 -0
package/README.md
CHANGED
|
@@ -1,169 +1,213 @@
|
|
|
1
1
|
# react-router-effect
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
the
|
|
8
|
-
|
|
9
|
-
|
|
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 =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
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
|
-
##
|
|
34
|
+
## Get started
|
|
33
35
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
-
|
|
47
|
-
|
|
44
|
+
```ts
|
|
45
|
+
// app/lib/route-factory.server.ts
|
|
46
|
+
import { ManagedRuntime } from "effect";
|
|
47
|
+
import { makeEffectRouteFactory } from "react-router-effect";
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
import { InvalidTodo, Todos } from "./services.server";
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
from its own response;
|
|
53
|
-
- otherwise it falls through to a **500**.
|
|
51
|
+
export const runtime = ManagedRuntime.make(Todos.layer);
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
62
|
+
// Routes import this `Respond`. No library export has the same name.
|
|
63
|
+
export const { Respond } = routeFactory;
|
|
64
|
+
```
|
|
63
65
|
|
|
64
|
-
###
|
|
66
|
+
### 2. Create the server app
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
```ts
|
|
72
|
+
// server/app.ts
|
|
73
|
+
import { ReactRouterHttp } from "react-router-effect/server";
|
|
74
74
|
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
109
|
-
import {
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
130
|
+
For development, read [Development with Vite](#development-with-vite).
|
|
131
|
+
|
|
132
|
+
### 5. Write routes as Effects
|
|
134
133
|
|
|
135
|
-
|
|
136
|
-
|
|
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/
|
|
138
|
+
// app/routes/todos.tsx
|
|
142
139
|
import { Effect } from "effect";
|
|
143
|
-
import {
|
|
144
|
-
import type {
|
|
140
|
+
import { data } from "react-router";
|
|
141
|
+
import type { EffectRouteArgs } from "react-router-effect";
|
|
145
142
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
`
|
|
158
|
-
|
|
159
|
-
loaders
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
229
|
+
### App-wide services with a runtime
|
|
181
230
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
531
|
+
export const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
199
532
|
Effect.gen(function* () {
|
|
200
|
-
const
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
Middleware
|
|
222
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
-
|
|
279
|
-
`
|
|
280
|
-
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|