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/README.md +724 -171
- package/dist/index.d.mts +2 -342
- package/dist/index.mjs +122 -33
- package/dist/rolldown-runtime-D8jQpDG7.mjs +12 -0
- package/dist/route-data-CWBxnNAA.d.mts +549 -0
- package/dist/server.d.mts +284 -0
- package/dist/server.mjs +483 -0
- package/dist/vite.d.mts +48 -0
- package/dist/vite.mjs +80 -0
- package/package.json +25 -9
- package/src/action-meta.ts +58 -0
- package/src/assets.ts +212 -0
- package/src/check.ts +213 -0
- package/src/errors.ts +3 -3
- package/src/factory.ts +301 -91
- package/src/index.ts +22 -1
- package/src/route-data.ts +151 -0
- package/src/server-entry.ts +11 -0
- package/src/server.ts +659 -0
- package/src/vite-entry.ts +2 -0
- package/src/vite.ts +169 -0
package/README.md
CHANGED
|
@@ -1,225 +1,778 @@
|
|
|
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
|
+
HTTP router, on Node or on Cloudflare Workers. 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
|
|
|
27
|
+
On Node:
|
|
28
|
+
|
|
26
29
|
```bash
|
|
27
|
-
vp add react-router-effect effect react-router
|
|
30
|
+
vp add react-router-effect effect react-router @effect/platform-node
|
|
28
31
|
```
|
|
29
32
|
|
|
30
|
-
`effect
|
|
33
|
+
`effect`, `react-router` and `@effect/platform-node` are peer dependencies. The library requires
|
|
34
|
+
Effect 4.1 (`effect@^4.1.0`) and Node 22 or later. Only `react-router-effect/vite` imports
|
|
35
|
+
`@effect/platform-node`, so another platform does not need it. The README of each example lists the
|
|
36
|
+
packages of its platform. Read [Platforms](#platforms).
|
|
31
37
|
|
|
32
|
-
##
|
|
38
|
+
## Get started
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
These steps serve an app on Node in production. [`examples/node`](examples/node) is an app with
|
|
41
|
+
these files. It also has development with Vite, tests against the production build, and benchmarks.
|
|
35
42
|
|
|
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` |
|
|
43
|
+
### 1. Create the route factory
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
The route factory has what every route Effect needs: the runtime that provides your services, and
|
|
46
|
+
the handlers for your domain errors.
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
-
|
|
47
|
-
|
|
48
|
+
```ts
|
|
49
|
+
// app/lib/route-factory.server.ts
|
|
50
|
+
import { ManagedRuntime } from "effect";
|
|
51
|
+
import { makeEffectRouteFactory } from "react-router-effect";
|
|
52
|
+
|
|
53
|
+
import { InvalidTodo, Todos } from "./services.server";
|
|
48
54
|
|
|
49
|
-
|
|
55
|
+
export const runtime = ManagedRuntime.make(Todos.layer);
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
// Pin the domain errors, then the handler types are inferred.
|
|
58
|
+
export const routeFactory = makeEffectRouteFactory<InvalidTodo>()((Respond) => ({
|
|
59
|
+
runtime,
|
|
60
|
+
errorHandlers: {
|
|
61
|
+
// A form error goes back to the form as data, with a 422.
|
|
62
|
+
InvalidTodo: (error) => Respond.early({ error: error.message }, 422),
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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.)
|
|
66
|
+
// Routes import this `Respond`. No library export has the same name.
|
|
67
|
+
export const { Respond } = routeFactory;
|
|
68
|
+
```
|
|
61
69
|
|
|
62
|
-
|
|
70
|
+
### 2. Create the server app
|
|
63
71
|
|
|
64
|
-
|
|
72
|
+
`App` is a layer of routes on Effect's `HttpRouter`. `ReactRouter.layer` loads the build with your
|
|
73
|
+
route factory. `ReactRouter.routes` registers React Router as the catch-all route.
|
|
74
|
+
`ReactRouterAssets.layer` registers the files of the client build. Your own routes and middleware go
|
|
75
|
+
in the same `Layer.mergeAll`.
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// server/app.ts
|
|
79
|
+
import { Layer } from "effect";
|
|
80
|
+
import { ReactRouter, ReactRouterAssets } from "react-router-effect/server";
|
|
81
|
+
|
|
82
|
+
import { routeFactory } from "../app/lib/route-factory.server";
|
|
83
|
+
|
|
84
|
+
export const App = Layer.mergeAll(ReactRouter.routes, ReactRouterAssets.layer).pipe(
|
|
85
|
+
Layer.provide(
|
|
86
|
+
ReactRouter.layer({
|
|
87
|
+
build: () => import("virtual:react-router/server-build"),
|
|
88
|
+
factory: routeFactory,
|
|
89
|
+
mode: import.meta.env.MODE,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 3. Serve it
|
|
96
|
+
|
|
97
|
+
`server.js` serves `App` with `HttpRouter.serve`, which also logs each request. When the server
|
|
98
|
+
starts, `App` loads the build and reads the client build. If the server cannot read the client
|
|
99
|
+
build, it fails to start with a `ClientBuildError` that names the resolved directory.
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
// server.js
|
|
103
|
+
import { createServer } from "node:http";
|
|
104
|
+
|
|
105
|
+
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
|
|
106
|
+
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
|
|
107
|
+
import { Layer } from "effect";
|
|
108
|
+
import { HttpRouter } from "effect/http";
|
|
109
|
+
|
|
110
|
+
import { App } from "./build/server/index.js";
|
|
111
|
+
|
|
112
|
+
NodeRuntime.runMain(
|
|
113
|
+
Layer.launch(
|
|
114
|
+
HttpRouter.serve(App).pipe(
|
|
115
|
+
Layer.provide(NodeHttpServer.layer(() => createServer(), { port: 3000 })),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 4. Build the server
|
|
122
|
+
|
|
123
|
+
Make `server/app.ts` the entry of the server build. `react-router build` then writes `App` to
|
|
124
|
+
`build/server/index.js`.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// vite.config.ts
|
|
128
|
+
import { reactRouter } from "@react-router/dev/vite";
|
|
129
|
+
import { defineConfig } from "vite";
|
|
130
|
+
|
|
131
|
+
export default defineConfig({
|
|
132
|
+
plugins: [reactRouter()],
|
|
133
|
+
environments: { ssr: { build: { rollupOptions: { input: "./server/app.ts" } } } },
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
react-router build
|
|
139
|
+
NODE_ENV=production node server.js # React's server build reads NODE_ENV
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
For development with Vite, the Node example's `server.js` serves the app through
|
|
143
|
+
`ReactRouterVite.layer`. Read [Development with Vite](examples/node/README.md#development-with-vite).
|
|
144
|
+
|
|
145
|
+
### 5. Write routes as Effects
|
|
146
|
+
|
|
147
|
+
A loader, action or middleware is an `Effect.fn`. It needs no wrapper. The adapter runs it with the
|
|
148
|
+
factory's runtime and error handlers.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// app/routes/todos.tsx
|
|
152
|
+
import { Effect } from "effect";
|
|
153
|
+
import { data } from "react-router";
|
|
154
|
+
import type { EffectRouteArgs } from "react-router-effect";
|
|
155
|
+
|
|
156
|
+
import type { Route } from "./+types/todos";
|
|
157
|
+
|
|
158
|
+
import { Todos } from "../lib/services.server";
|
|
159
|
+
|
|
160
|
+
export const loader = Effect.fn("loader.todos")(function* () {
|
|
161
|
+
return { todos: yield* (yield* Todos).list };
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// `InvalidTodo` is not handled here. The route factory turns it into `{ error }` data with a 422.
|
|
165
|
+
export const action = Effect.fn("action.todos")(function* ({
|
|
166
|
+
request,
|
|
167
|
+
}: EffectRouteArgs<Route.ActionArgs>) {
|
|
168
|
+
const form = yield* Effect.promise(() => request.formData());
|
|
169
|
+
const todo = yield* (yield* Todos).add(String(form.get("title")));
|
|
170
|
+
return data({ todo }, { status: 201 });
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`EffectRouteArgs` keeps hooks that take `typeof action` or `typeof loader` typed, such as
|
|
175
|
+
`useFetcher<typeof action>()`. [Plain Effect loaders, actions and
|
|
176
|
+
middleware](#plain-effect-loaders-actions-and-middleware) explains why. A route function that does
|
|
177
|
+
not return an Effect passes through to React Router unchanged, so promise loaders keep working in
|
|
178
|
+
the same app.
|
|
179
|
+
|
|
180
|
+
## Platforms
|
|
181
|
+
|
|
182
|
+
`App` is the same layer on every platform. Only the file that serves it changes. Effect has two
|
|
183
|
+
functions that serve it:
|
|
184
|
+
|
|
185
|
+
- `HttpRouter.serve(App)` with a server layer, such as `NodeHttpServer.layer` on Node.
|
|
186
|
+
- `HttpRouter.toWebHandler(App)`, which returns a fetch handler, such as a Cloudflare Worker's.
|
|
187
|
+
|
|
188
|
+
| Platform | Example | Serves `App` with |
|
|
189
|
+
| ------------------ | -------------------------------------------- | --------------------------------------------- |
|
|
190
|
+
| Node | [`examples/node`](examples/node) | `HttpRouter.serve` and `NodeHttpServer.layer` |
|
|
191
|
+
| Cloudflare Workers | [`examples/cloudflare`](examples/cloudflare) | `HttpRouter.toWebHandler` |
|
|
192
|
+
|
|
193
|
+
The README of each example has the details of its platform. These rules apply on every platform:
|
|
194
|
+
|
|
195
|
+
- App-wide services go in the route factory's `runtime`. Read
|
|
196
|
+
[App-wide services with a runtime](#app-wide-services-with-a-runtime).
|
|
197
|
+
- Per-request services go in a router middleware. Read [Per-request services](#per-request-services).
|
|
198
|
+
- A platform service, such as a tracer, goes where the serve function reads it. `HttpRouter.serve`
|
|
199
|
+
reads the services that `Layer.provide` gives to its layer. `HttpRouter.toWebHandler` reads the
|
|
200
|
+
services that `Layer.provideMerge` adds to the layer that it gets.
|
|
201
|
+
|
|
202
|
+
## Handle errors
|
|
203
|
+
|
|
204
|
+
An Effect can short-circuit in three ways, with the `Respond` helpers:
|
|
205
|
+
|
|
206
|
+
| Helper | Outcome | Where it lands |
|
|
207
|
+
| ------------------------------ | ------- | --------------------------------------------------- |
|
|
208
|
+
| `Respond.early(value, init?)` | recover | resolves with `data(value, init)` → `useLoaderData` |
|
|
209
|
+
| `Respond.throw(value, init?)` | throw | rejects with `data(value, init)` → error boundary |
|
|
210
|
+
| `Respond.redirect(url, init?)` | throw | rejects with a redirect `Response` |
|
|
211
|
+
|
|
212
|
+
Declare your app's domain errors as the type argument of `makeEffectRouteFactory`. You can register
|
|
213
|
+
one handler for each. A handler remaps its error. It returns one of these:
|
|
214
|
+
|
|
215
|
+
- A library route error: `Respond.early(...)`, `Respond.throw(...)` or `Respond.redirect(...)`.
|
|
216
|
+
- An `Effect`: `Effect.succeed(value)` recovers with `value`, and `Effect.fail(response)` throws a
|
|
217
|
+
`Response` or `DataWithResponseInit`.
|
|
65
218
|
|
|
66
219
|
```ts
|
|
67
|
-
// app/route.server.ts
|
|
68
220
|
import { Data, Effect } from "effect";
|
|
69
|
-
import { makeEffectRouteFactory } from "react-router-effect";
|
|
70
221
|
|
|
71
222
|
class FormError extends Data.TaggedError("FormError")<{ reply: SubmissionResponse }> {}
|
|
72
223
|
class BadInputError extends Data.TaggedError("BadInputError")<{ message: string }> {}
|
|
73
224
|
class DbError extends Data.TaggedError("DbError")<{ query: string }> {}
|
|
74
225
|
|
|
75
|
-
//
|
|
76
|
-
// so it falls through to the 500 default.
|
|
226
|
+
// `DbError` has no handler below, so it falls through to the 500 default.
|
|
77
227
|
type DomainErrors = FormError | BadInputError | DbError;
|
|
78
228
|
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
229
|
+
export const routeFactory = makeEffectRouteFactory<DomainErrors>()((Respond) => ({
|
|
230
|
+
// App-specific helpers. The returned `Respond` has them next to the base helpers.
|
|
231
|
+
respond: {
|
|
232
|
+
formError: (reply: SubmissionResponse) => new FormError({ reply }),
|
|
233
|
+
},
|
|
234
|
+
errorHandlers: {
|
|
235
|
+
// Keys autocomplete to your domain-error tags, and `error` is typed from its key.
|
|
236
|
+
// Recover: pass the reply to the component.
|
|
237
|
+
FormError: (error) => Respond.early({ reply: error.reply }),
|
|
238
|
+
// Throw: send to the error boundary.
|
|
239
|
+
BadInputError: (error) => Effect.fail(new Response(error.message, { status: 400 })),
|
|
240
|
+
},
|
|
241
|
+
}));
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
A declared domain error can have no handler. If it implements `HttpServerRespondable`, it renders
|
|
245
|
+
its own response. A registered handler still takes precedence. Otherwise it falls through to a 500.
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
import { HttpServerRespondable, HttpServerResponse } from "effect/http";
|
|
249
|
+
|
|
250
|
+
class NotAuthorizedError extends Data.TaggedError("NotAuthorizedError")<{}> {
|
|
251
|
+
[HttpServerRespondable.symbol]() {
|
|
252
|
+
return HttpServerResponse.json({ error: "Not authorized" }, { status: 403 }).pipe(Effect.orDie);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
98
255
|
```
|
|
99
256
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
257
|
+
Any other error that a route consumes must be handled in the route. This includes a
|
|
258
|
+
service-specific error that is not a declared domain error. Catch it or map it. This gives the app
|
|
259
|
+
defaults for declared errors. The errors of one feature or service need explicit handling.
|
|
260
|
+
[The route check](#the-route-check) reports an unhandled error at compile time.
|
|
104
261
|
|
|
105
|
-
|
|
262
|
+
## Provide services
|
|
263
|
+
|
|
264
|
+
### App-wide services with a runtime
|
|
265
|
+
|
|
266
|
+
The `runtime` is an Effect [`ManagedRuntime`](https://effect.website). The factory builds it once and
|
|
267
|
+
reuses it for every request. Route Effects require its services directly, with no `Effect.provide`
|
|
268
|
+
in the route. The runtime's services are the allowed requirements of a route Effect. A route that
|
|
269
|
+
requires another service is a compile error. Without a `runtime`, a route Effect must require
|
|
270
|
+
nothing.
|
|
106
271
|
|
|
107
272
|
```ts
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
273
|
+
export const loader = Effect.fn("loader.profile")(function* ({
|
|
274
|
+
params,
|
|
275
|
+
}: EffectRouteArgs<Route.LoaderArgs>) {
|
|
276
|
+
const svc = yield* MyService; // the runtime provides MyService
|
|
277
|
+
return { profile: yield* svc.load(params.id) };
|
|
278
|
+
});
|
|
279
|
+
```
|
|
112
280
|
|
|
113
|
-
|
|
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
|
-
});
|
|
281
|
+
`errorHandlers` is optional. A factory can have a runtime only, or nothing:
|
|
121
282
|
|
|
122
|
-
|
|
283
|
+
```ts
|
|
284
|
+
makeEffectRouteFactory()(() => ({ runtime }));
|
|
285
|
+
makeEffectRouteFactory()(() => ({}));
|
|
123
286
|
```
|
|
124
287
|
|
|
125
|
-
|
|
288
|
+
### Per-request services
|
|
289
|
+
|
|
290
|
+
The runtime provides app-wide services. A request-scoped service is the current user, a request id,
|
|
291
|
+
or a transaction for one request. There are two ways to provide one.
|
|
292
|
+
|
|
293
|
+
A route-scoped router middleware on `ReactRouter.routes` provides services to React Router's
|
|
294
|
+
requests. Every middleware, loader and action sees them, and no React Router middleware has to set
|
|
295
|
+
them:
|
|
126
296
|
|
|
127
297
|
```ts
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
298
|
+
// server/app.ts
|
|
299
|
+
const RequestServices = HttpRouter.middleware((app) =>
|
|
300
|
+
Effect.provideServiceEffect(
|
|
301
|
+
app,
|
|
302
|
+
RequestInfo,
|
|
303
|
+
HttpServerRequest.HttpServerRequest.use((request) =>
|
|
304
|
+
Effect.succeed({
|
|
305
|
+
id: request.headers["x-request-id"] ?? crypto.randomUUID(),
|
|
306
|
+
startedAt: Date.now(),
|
|
307
|
+
}),
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
).layer;
|
|
311
|
+
|
|
312
|
+
export const App = Layer.mergeAll(
|
|
313
|
+
ReactRouter.routes.pipe(Layer.provide(RequestServices)),
|
|
314
|
+
ReactRouterAssets.layer,
|
|
315
|
+
).pipe(
|
|
316
|
+
Layer.provide(
|
|
317
|
+
ReactRouter.layer({
|
|
318
|
+
build: () => import("virtual:react-router/server-build"),
|
|
319
|
+
factory: routeFactory,
|
|
320
|
+
mode: import.meta.env.MODE,
|
|
321
|
+
}),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
131
324
|
```
|
|
132
325
|
|
|
133
|
-
|
|
326
|
+
A [middleware](https://reactrouter.com/how-to/middleware) can also provide services. An effect
|
|
327
|
+
middleware provides them around `next()`, and everything downstream of `next()` can require them:
|
|
134
328
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
329
|
+
```ts
|
|
330
|
+
// app/root.tsx
|
|
331
|
+
export const middleware = [
|
|
332
|
+
Effect.fn("middleware.visitor")(function* ({
|
|
333
|
+
request,
|
|
334
|
+
next,
|
|
335
|
+
}: EffectMiddlewareArgs<Route.MiddlewareFunction>) {
|
|
336
|
+
const name = yield* readVisitor(request);
|
|
337
|
+
return yield* next().pipe(
|
|
338
|
+
Effect.provideService(Visitor, { name }),
|
|
339
|
+
Effect.annotateLogs({ visitor: name }),
|
|
340
|
+
);
|
|
341
|
+
}),
|
|
342
|
+
];
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Tell the factory which services the request provides with a context key:
|
|
139
346
|
|
|
140
347
|
```ts
|
|
141
|
-
// app/
|
|
142
|
-
import {
|
|
143
|
-
import {
|
|
144
|
-
import type { Route } from "./+types/profile";
|
|
348
|
+
// app/lib/route-factory.server.ts
|
|
349
|
+
import { createContext } from "react-router";
|
|
350
|
+
import type { RequestContextKey } from "react-router-effect";
|
|
145
351
|
|
|
146
|
-
export const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
352
|
+
export const requestContext: RequestContextKey<RequestInfo | Visitor> = createContext();
|
|
353
|
+
|
|
354
|
+
export const routeFactory = makeEffectRouteFactory<DomainErrors>()(() => ({
|
|
355
|
+
runtime,
|
|
356
|
+
requestContext,
|
|
357
|
+
}));
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
A route Effect can then require the runtime's services and the request's services. Any other
|
|
361
|
+
requirement is a compile error. `RequestContextKey<ReqServices>` is an alias of
|
|
362
|
+
`RouterContext<Context.Context<ReqServices>>`.
|
|
363
|
+
|
|
364
|
+
The adapter sets this key to the request fiber's context before React Router runs. That context
|
|
365
|
+
holds the server span, `HttpServerRequest` and the services of the router middleware. Do not set the
|
|
366
|
+
key to a new context in a React Router middleware, as an app without the adapter does. A new context
|
|
367
|
+
replaces those services for every function after that middleware. Provide request services with a
|
|
368
|
+
router middleware or an effect middleware, as this section shows.
|
|
369
|
+
|
|
370
|
+
## The adapter
|
|
371
|
+
|
|
372
|
+
`react-router-effect/server` is a React Router adapter for Effect's HTTP router, on every platform
|
|
373
|
+
that the router runs on. It is the Effect counterpart of `@react-router/express`. It
|
|
374
|
+
handles everything between an `HttpServerRequest` and React Router. That is the Web `Request` and
|
|
375
|
+
its abort signal, the route factory, and the client build. It also converts React Router's
|
|
376
|
+
`Response`, with its streamed body and its cookies. The app keeps its own server, middleware and
|
|
377
|
+
routes.
|
|
378
|
+
|
|
379
|
+
The adapter's API is unstable, and a minor version can change it. Every export of
|
|
380
|
+
`react-router-effect/server` and `react-router-effect/vite` has the JSDoc tag `@stability unstable`.
|
|
381
|
+
The route factory and the other exports of `react-router-effect` are stable.
|
|
382
|
+
|
|
383
|
+
React Router keeps doing what it does for any other server adapter: documents, `.data` requests,
|
|
384
|
+
streamed promises, resource routes, the lazy route manifest and middleware. Before React Router
|
|
385
|
+
sees the build, the adapter runs every loader, action and middleware through your route factory:
|
|
386
|
+
|
|
387
|
+
- A route function that returns an Effect runs with the factory's runtime and request context.
|
|
388
|
+
Its failures go through the factory's error handlers.
|
|
389
|
+
- Any other route function passes through. Promise loaders and `makeLoader` wrappers keep working.
|
|
390
|
+
|
|
391
|
+
The layers:
|
|
392
|
+
|
|
393
|
+
- `ReactRouter.layer(options)` provides the `ReactRouter` service. Its options are these:
|
|
394
|
+
- `build` is the server build, or a function that loads it.
|
|
395
|
+
- `factory` is the route factory.
|
|
396
|
+
- `mode` is React Router's server mode, and you must give it. In a Vite app, pass
|
|
397
|
+
`import.meta.env.MODE`. React Router runs in production mode for any mode but `development`
|
|
398
|
+
and `test`, and so does the layer. At layer build, the layer loads a build value once, or a
|
|
399
|
+
build function in production mode. In `development` and `test`, a build function loads for
|
|
400
|
+
each request.
|
|
401
|
+
- `loadContext` makes React Router's load context for each request. Read
|
|
402
|
+
[The load context](#the-load-context).
|
|
403
|
+
- `ReactRouter.routes` registers React Router as the catch-all route on `*`.
|
|
404
|
+
- `ReactRouterAssets.layer` registers the files of the client build. Read
|
|
405
|
+
[The client build](#the-client-build).
|
|
406
|
+
- `ReactRouterVite.layer`, from `react-router-effect/vite`, serves the app in development on Node.
|
|
407
|
+
Read [Development with Vite](examples/node/README.md#development-with-vite).
|
|
408
|
+
|
|
409
|
+
Provide one value of `ReactRouter.layer` to every layer that reads the service, as `App` does with
|
|
410
|
+
`Layer.provide`. Two values load the build twice.
|
|
411
|
+
|
|
412
|
+
### The load context
|
|
413
|
+
|
|
414
|
+
`loadContext` does for the adapter what `getLoadContext` does for `@react-router/express`. It
|
|
415
|
+
returns an Effect of a `RouterContextProvider`. Plain loaders and actions, and `handleRequest` in
|
|
416
|
+
`entry.server.tsx`, read its values with `context.get`. Route Effects read services from their
|
|
417
|
+
Effect context. So give route Effects services with router middleware, not with `loadContext`.
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
ReactRouter.layer({
|
|
421
|
+
build: () => import("virtual:react-router/server-build"),
|
|
422
|
+
factory: routeFactory,
|
|
423
|
+
mode: import.meta.env.MODE,
|
|
424
|
+
loadContext: (request) =>
|
|
425
|
+
Effect.sync(() => {
|
|
426
|
+
const context = new RouterContextProvider();
|
|
427
|
+
context.set(localeKey, request.headers["accept-language"] ?? "en");
|
|
428
|
+
return context;
|
|
429
|
+
}),
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The Effect gets the services of the layer and the services of the request. The services of the
|
|
434
|
+
request, such as its scope, win. The adapter adds its own keys to the provider. If the Effect dies,
|
|
435
|
+
the client gets an empty 500.
|
|
436
|
+
|
|
437
|
+
### Routes and middleware
|
|
438
|
+
|
|
439
|
+
The route that a request matches decides which middleware runs on it. Effect has three kinds of
|
|
440
|
+
middleware:
|
|
441
|
+
|
|
442
|
+
- A server middleware, the `middleware` option of `HttpRouter.serve`, wraps every request. It also
|
|
443
|
+
wraps the send of the response, so a change to the response with `Effect.map` does not reach the
|
|
444
|
+
client. `HttpMiddleware.compression` works there.
|
|
445
|
+
- A global router middleware, `HttpRouter.middleware(fn, { global: true })`, wraps every route.
|
|
446
|
+
- A route-scoped middleware, `HttpRouter.middleware(fn).layer`, applies only to the routes of the
|
|
447
|
+
layers that you provide it to.
|
|
448
|
+
|
|
449
|
+
Route-scoped middleware selects the middleware of each kind of request. `HttpRouter.serve` logs
|
|
450
|
+
every request. To leave file requests out of that log, provide `HttpRouter.disableLogger` to the
|
|
451
|
+
assets layer only:
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
export const App = Layer.mergeAll(
|
|
455
|
+
ReactRouter.routes.pipe(Layer.provide(RequestServices)),
|
|
456
|
+
ReactRouterAssets.layer.pipe(Layer.provide(HttpRouter.disableLogger)),
|
|
457
|
+
HttpRouter.middleware(HttpMiddleware.compression(), { global: true }),
|
|
458
|
+
).pipe(
|
|
459
|
+
Layer.provide(
|
|
460
|
+
ReactRouter.layer({
|
|
461
|
+
build: () => import("virtual:react-router/server-build"),
|
|
462
|
+
factory: routeFactory,
|
|
463
|
+
mode: import.meta.env.MODE,
|
|
152
464
|
}),
|
|
153
465
|
),
|
|
154
|
-
|
|
466
|
+
);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The order of two route-scoped middleware layers on one route is not fixed. If the order matters,
|
|
470
|
+
join them into one middleware with `combine`:
|
|
471
|
+
`HttpRouter.middleware(first).combine(HttpRouter.middleware(second)).layer`.
|
|
472
|
+
|
|
473
|
+
These rules apply to the routes:
|
|
474
|
+
|
|
475
|
+
- An exact route wins over a route with a parameter, and both win over a wildcard. So your own
|
|
476
|
+
routes and the files of the client build win over React Router's `*`.
|
|
477
|
+
- A route of your own on `*` or `/*`, for a method that React Router's route has, collides with it.
|
|
478
|
+
Then the server fails to start. A route of your own with the path of a file of the client build
|
|
479
|
+
fails the same way.
|
|
480
|
+
- If a HEAD request matches no HEAD route, it uses the GET route of its path. `ReactRouter.routes`
|
|
481
|
+
registers no HEAD route. So a GET route of your own answers HEAD, as it does in Express.
|
|
482
|
+
- React Router's route takes the methods of `HttpMethod.all`: GET, POST, PUT, DELETE, PATCH,
|
|
483
|
+
OPTIONS, TRACE and QUERY, and HEAD through GET. A request with another method, such as PROPFIND,
|
|
484
|
+
gets a 404 from the router.
|
|
485
|
+
- Register `ReactRouter.routes` at the root of the router. Under a prefix, React Router does not
|
|
486
|
+
get the path that it expects. To serve the app under a path, set React Router's `basename`.
|
|
487
|
+
- The router ignores the case of the path. On a file system that is case-sensitive, a request for a
|
|
488
|
+
file with the wrong case matches the file's route and gets a 404.
|
|
489
|
+
|
|
490
|
+
### The client build
|
|
491
|
+
|
|
492
|
+
`ReactRouterAssets.layer` serves `build.assetsBuildDirectory` under `build.publicPath`, as the two
|
|
493
|
+
`express.static` lines of React Router's Express template do. It needs the platform's file system,
|
|
494
|
+
which `NodeHttpServer.layer` provides. A Cloudflare Worker serves the client build from its static
|
|
495
|
+
assets, and does not use this layer.
|
|
496
|
+
|
|
497
|
+
- The hashed directory is the directory of the build's manifest URL, such as `/assets`. React
|
|
498
|
+
Router's build writes files with a content hash in their names there. The layer caches them for a
|
|
499
|
+
year as immutable. A request under the hashed directory gets a 404 for a file that the directory
|
|
500
|
+
does not have. A path with `..` does not get out of the hashed directory.
|
|
501
|
+
- Each other file gets its own route for GET and HEAD, and the layer caches it for an hour. At
|
|
502
|
+
server start, the layer reads the client build. If a file is added outside the hashed directory
|
|
503
|
+
later, the layer serves it only after a restart.
|
|
504
|
+
- A directory with an `index.html`, such as a prerendered page, serves that file. Its path without
|
|
505
|
+
the trailing slash redirects to the path with it. A root `index.html`, such as a prerendered home
|
|
506
|
+
page, serves `/`, and React Router's catch-all yields to it.
|
|
507
|
+
- If the manifest URL is not under `publicPath`, or it is at the root of `publicPath`, there is no
|
|
508
|
+
hashed directory. Then the layer caches every file for an hour.
|
|
509
|
+
- The layer serves no dotfiles, such as `.vite/manifest.json`, as `express.static` does.
|
|
510
|
+
- The layer registers nothing for a build that loads for each request, such as a development build.
|
|
511
|
+
Vite serves the client there. It also registers nothing for a `publicPath` on another origin.
|
|
512
|
+
- `assetsBuildDirectory` is relative to the working directory of the server. If the layer cannot
|
|
513
|
+
read it, the server fails to start with a `ClientBuildError` that names the resolved directory.
|
|
514
|
+
If a file name has a `*`, the server fails to start with a `ClientBuildError` too, because the
|
|
515
|
+
router cannot match that name.
|
|
516
|
+
|
|
517
|
+
### Requests
|
|
518
|
+
|
|
519
|
+
The Web request comes from `HttpServerRequest.toWeb`, body included. On Effect's Node server, the
|
|
520
|
+
request has the changes that middleware makes to the `HttpServerRequest`, such as the headers of
|
|
521
|
+
`HttpMiddleware.xForwardedHeaders`. On a Web server, such as a Cloudflare Worker, React Router gets
|
|
522
|
+
the runtime's own request. The README of each example describes the requests of its platform.
|
|
523
|
+
|
|
524
|
+
If the client leaves while React Router runs or while the body streams, the response ends early, and
|
|
525
|
+
the request signal aborts. A completed response never aborts it. On Cloudflare Workers, the abort
|
|
526
|
+
comes from Cloudflare's own request signal. For the conditions of that abort, read the
|
|
527
|
+
[Cloudflare example](examples/cloudflare/README.md#requests).
|
|
528
|
+
|
|
529
|
+
React Router logs and answers the errors of route code. If the adapter's own steps fail, for example
|
|
530
|
+
a `loadContext` that dies, the adapter logs the error with `Effect.logError`. Then it answers an
|
|
531
|
+
empty 500. The log goes to the loggers of the app's Effect runtime.
|
|
532
|
+
|
|
533
|
+
### Cookies
|
|
534
|
+
|
|
535
|
+
Set cookies from route code with React Router's cookie helpers, `createCookie` and the session
|
|
536
|
+
storages, as in any React Router app. The adapter converts React Router's `Set-Cookie` values into
|
|
537
|
+
the response's `cookies`, as `HttpServerResponse.fromWeb` does. A router middleware on
|
|
538
|
+
`ReactRouter.routes` can read and change them like any other cookie. A response that replaces React
|
|
539
|
+
Router's has none of them.
|
|
540
|
+
|
|
541
|
+
Effect keeps one cookie per name and only the attributes it knows: `Max-Age`, `Domain`, `Path`,
|
|
542
|
+
`Expires`, `HttpOnly`, `Secure`, `Partitioned`, `Priority` and `SameSite`. One response can set the
|
|
543
|
+
same cookie name twice, for example to expire it on one path and set it on another. Then Effect
|
|
544
|
+
sends only the last one.
|
|
545
|
+
|
|
546
|
+
Effect accepts only cookie names that are RFC 6265 tokens: letters, digits and
|
|
547
|
+
``!#$%&'*+-.^_`|~``. React Router's `createCookie` and session storages also accept other names,
|
|
548
|
+
such as `app/session` or `user:prefs`. The adapter does not send a cookie with such a name. It logs
|
|
549
|
+
an error with the cookie's name instead. Give every cookie a token name.
|
|
550
|
+
|
|
551
|
+
### Content type
|
|
552
|
+
|
|
553
|
+
Effect gives a streamed body without a content type `application/octet-stream`. A resource route
|
|
554
|
+
that returns such a body sends that type, and the browser downloads the response. Examples are a
|
|
555
|
+
`ReadableStream`, bytes, or the body of another service's response. With Express, the response has
|
|
556
|
+
no content type, and the browser guesses one. Set `Content-Type` in the route:
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
return new Response(Readable.toWeb(file) as ReadableStream, {
|
|
560
|
+
headers: { "content-type": "application/pdf" },
|
|
561
|
+
});
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
### Tracing
|
|
565
|
+
|
|
566
|
+
When the request has a server span, the adapter traces React Router. If the app provides a tracer
|
|
567
|
+
that exports spans, such as `OtelTracer`, Effect's HTTP server makes the server span. Give the
|
|
568
|
+
tracer where the serve function reads it, as [Platforms](#platforms) tells. Effect serves each
|
|
569
|
+
request with the services of that layer. A tracer that the serve function does not read makes no
|
|
570
|
+
server span, so the adapter adds no spans. The README of each example shows where its tracer goes.
|
|
571
|
+
|
|
572
|
+
Each middleware, loader and action runs in a span of its own, such as
|
|
573
|
+
`react-router.loader routes/todos`.
|
|
574
|
+
The spans nest as React Router calls the functions. This is the example's `GET /todos`:
|
|
575
|
+
|
|
576
|
+
```text
|
|
577
|
+
GET the server span, with http.route=/todos
|
|
578
|
+
└─ react-router.middleware root
|
|
579
|
+
└─ middleware.visitor the Effect middleware's own span, around next()
|
|
580
|
+
├─ react-router.loader root
|
|
581
|
+
│ └─ loader.root the route Effect's span
|
|
582
|
+
└─ react-router.loader routes/todos
|
|
583
|
+
└─ loader.todos
|
|
155
584
|
```
|
|
156
585
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
a
|
|
586
|
+
A middleware's span covers everything its `next()` runs: the next middleware, then the loaders and
|
|
587
|
+
the action. Route Effects nest under the span of their function. Effect's OpenTelemetry tracer makes
|
|
588
|
+
the current Effect span the active OpenTelemetry context. So the clients that an OpenTelemetry
|
|
589
|
+
instrumentation traces nest there too, such as a database driver that a promise loader calls.
|
|
590
|
+
Without a server span, or with Effect's native tracer, whose spans go nowhere, the adapter adds no
|
|
591
|
+
spans and no cost.
|
|
162
592
|
|
|
163
|
-
|
|
593
|
+
The adapter sets `http.route` on the server span to the pattern of the matched routes, such as
|
|
594
|
+
`/todos/:id`, once per request. It does this also for a page that runs no route function. A router
|
|
595
|
+
middleware that opens a span of its own does not change that: the pattern still goes on the server
|
|
596
|
+
span. A request that matches no route keeps the router's `*`. `ReactRouter.layer` reads the tracer
|
|
597
|
+
once, at layer build. So it does not see a tracer that the app provides only inside a request.
|
|
164
598
|
|
|
165
|
-
|
|
166
|
-
|
|
599
|
+
## Route functions
|
|
600
|
+
|
|
601
|
+
### Plain Effect loaders, actions and middleware
|
|
167
602
|
|
|
168
603
|
```ts
|
|
169
|
-
|
|
604
|
+
export const loader = Effect.fn("loader.dashboard")(function* () {
|
|
605
|
+
const { organization } = yield* AuthContext.getScope;
|
|
606
|
+
return { organization };
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
export const action = Effect.fn("action.createTax")(function* ({
|
|
610
|
+
request,
|
|
611
|
+
}: EffectRouteArgs<Route.ActionArgs>) {
|
|
612
|
+
const submission = yield* parseSubmission(request);
|
|
613
|
+
return data({ reply: submission.reply() }, { status: 201 });
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
export const middleware = [
|
|
617
|
+
Effect.fn("middleware.adminOnly")(function* ({
|
|
618
|
+
next,
|
|
619
|
+
}: EffectMiddlewareArgs<Route.MiddlewareFunction>) {
|
|
620
|
+
const user = yield* AuthContext.getUser;
|
|
621
|
+
if (user.role !== "admin") return redirect("/settings");
|
|
622
|
+
return yield* next().pipe(Effect.annotateLogs({ userId: user.id }));
|
|
623
|
+
}),
|
|
624
|
+
];
|
|
625
|
+
```
|
|
170
626
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
627
|
+
- Route data: Effects are branded with React Router's `unstable_SerializesTo`. So
|
|
628
|
+
`Route.ComponentProps` types the data of an Effect loader or action, including what the factory
|
|
629
|
+
recovers.
|
|
630
|
+
- Hooks that take `typeof loader` or `typeof action`, such as `useFetcher<typeof action>()`,
|
|
631
|
+
read the data through React Router's `SerializeFrom`. It treats a function as a client
|
|
632
|
+
function when its argument has the members of client loader args, and gives client functions
|
|
633
|
+
their raw return value. Typegen's `Route.LoaderArgs` and `Route.ActionArgs` have those same
|
|
634
|
+
members, so for an Effect the hook would type the data as the Effect itself.
|
|
635
|
+
- Take the args through `EffectRouteArgs<Route.LoaderArgs>`. It makes `pattern` optional, so the
|
|
636
|
+
argument no longer matches and the hook reads the brand. A loader without args needs nothing.
|
|
637
|
+
- `RouteData<typeof loader>` reads the brand directly and applies React Router's serialization.
|
|
638
|
+
The brand records the data before serialization: it has to keep Effect's subtype order, and
|
|
639
|
+
React Router's `Serialize` does not.
|
|
640
|
+
- Middleware: an effect middleware gets `next` as an effect in its args. Services, log annotations
|
|
641
|
+
and the span that it provides around `next()` reach every route Effect downstream. `context.set`
|
|
642
|
+
keeps working. You cannot annotate a route array with an effect middleware as
|
|
643
|
+
`Route.MiddlewareFunction[]`.
|
|
644
|
+
- Action metadata: `actionMeta` in the factory config is read after every action resolves. The
|
|
645
|
+
adapter attaches it to the action's data under `__meta`.
|
|
646
|
+
- Tests: `routeFactory.handlers.loader(loader)(args)` runs a route function as the server does.
|
|
647
|
+
|
|
648
|
+
### Register what the factory recovers
|
|
649
|
+
|
|
650
|
+
A plain Effect loader cannot see the factory's handlers. Declare what they recover once:
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
declare module "react-router-effect" {
|
|
654
|
+
interface Register {
|
|
655
|
+
recover: { FormValidationError: { reply: SubmissionResponse } };
|
|
656
|
+
actionMeta: { realtimeEventIds: ReadonlyArray<string> };
|
|
174
657
|
}
|
|
175
658
|
}
|
|
176
659
|
```
|
|
177
660
|
|
|
178
|
-
|
|
661
|
+
Declare the types directly. Reading them from `typeof routeFactory` is circular.
|
|
662
|
+
|
|
663
|
+
### The route check
|
|
179
664
|
|
|
180
|
-
|
|
665
|
+
`RouteEffectCheck` reads the route modules that React Router's typegen registers. It fails to
|
|
666
|
+
compile in three cases:
|
|
181
667
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
668
|
+
- A route Effect can fail with an error that the factory does not handle.
|
|
669
|
+
- A route Effect needs a service that the runtime and the request context do not provide.
|
|
670
|
+
- `Register` disagrees with the factory.
|
|
185
671
|
|
|
186
672
|
```ts
|
|
187
|
-
|
|
188
|
-
import { ManagedRuntime } from "effect";
|
|
189
|
-
export const appRuntime = ManagedRuntime.make(AppLayer); // provides Database, MyService, ...
|
|
673
|
+
import type { RouteEffectCheck } from "react-router-effect/server";
|
|
190
674
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
675
|
+
export const routeEffects: RouteEffectCheck<typeof routeFactory> = {};
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
```text
|
|
679
|
+
Type '{}' is missing the following properties from type ...:
|
|
680
|
+
"routes/billing#loader must handle PlaidApiError",
|
|
681
|
+
"routes/admin#middleware needs a provider for app/Session"
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
### Requirements and limits
|
|
196
685
|
|
|
197
|
-
|
|
198
|
-
|
|
686
|
+
- Effect 4.1 or later, and `skipLibCheck`. The brand augments `Effect`, and TypeScript cannot test
|
|
687
|
+
the variance annotations of that augmentation.
|
|
688
|
+
- For the adapter, React Router 8.3 or later. The route factory, `makeLoader`, `makeAction` and
|
|
689
|
+
`makeMiddleware` also support React Router 7.16 or later.
|
|
690
|
+
- A route module export must be a function. A bare `Effect` value is not a loader.
|
|
691
|
+
- Prerendering (`prerender`) and RSC mode call the build without the adapter.
|
|
692
|
+
|
|
693
|
+
## Without the Effect HTTP server
|
|
694
|
+
|
|
695
|
+
If your app serves React Router from Express, `@react-router/node`, a serverless platform or another
|
|
696
|
+
server, the adapter does not apply. The same route factory still returns `makeLoader`, `makeAction`
|
|
697
|
+
and `makeMiddleware`. Each wraps one route function. The wrapper runs the Effect with the factory's
|
|
698
|
+
runtime and error handlers, and returns a function that React Router calls.
|
|
699
|
+
|
|
700
|
+
```ts
|
|
701
|
+
// app/routes/profile.ts
|
|
702
|
+
import { Effect } from "effect";
|
|
703
|
+
import { makeLoader, Respond } from "../route.server.ts";
|
|
704
|
+
import type { Route } from "./+types/profile";
|
|
705
|
+
|
|
706
|
+
export const loader = makeLoader((args: Route.LoaderArgs) =>
|
|
199
707
|
Effect.gen(function* () {
|
|
200
|
-
const
|
|
201
|
-
|
|
708
|
+
const profile = yield* getProfile(args.params.id); // can fail with FormError / BadInputError
|
|
709
|
+
if (!profile.public) {
|
|
710
|
+
yield* Respond.early({ restricted: true }); // recover with typed data
|
|
711
|
+
}
|
|
712
|
+
return { profile };
|
|
202
713
|
}),
|
|
203
714
|
);
|
|
204
715
|
```
|
|
205
716
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
717
|
+
Take `makeLoader`, `makeAction`, `makeMiddleware` and `Respond` from the factory:
|
|
718
|
+
|
|
719
|
+
```ts
|
|
720
|
+
// app/route.server.ts
|
|
721
|
+
export const { makeLoader, makeAction, makeMiddleware, Respond } =
|
|
722
|
+
makeEffectRouteFactory<DomainErrors>()((Respond) => ({
|
|
723
|
+
runtime: appRuntime,
|
|
724
|
+
errorHandlers: {/* ... */},
|
|
725
|
+
}));
|
|
726
|
+
```
|
|
209
727
|
|
|
210
|
-
`
|
|
728
|
+
`makeLoader` and `makeAction` are the same wrapper. The wrapper computes the resolved type from the
|
|
729
|
+
effect and your handlers:
|
|
211
730
|
|
|
212
731
|
```ts
|
|
213
|
-
|
|
214
|
-
|
|
732
|
+
type LoaderData = Route.ComponentProps["loaderData"];
|
|
733
|
+
// { profile: Profile } | DataWithResponseInit<{ restricted: boolean }>
|
|
734
|
+
// ( BadInputError throws, so it never appears here )
|
|
215
735
|
```
|
|
216
736
|
|
|
217
|
-
|
|
737
|
+
Without the adapter, an unhandled error is a compile error at the `makeLoader` or `makeAction` call.
|
|
738
|
+
The message lists the unhandled errors. An effect can also require a service that neither the
|
|
739
|
+
`runtime` nor the `requestContext` provides. That fails the same way, and the message lists the
|
|
740
|
+
missing service.
|
|
218
741
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
742
|
+
### Middleware
|
|
743
|
+
|
|
744
|
+
`makeMiddleware` wraps a [middleware](https://reactrouter.com/how-to/middleware) function the same
|
|
745
|
+
way, with the same `errorHandlers`. Pin it to the route's generated `Route.MiddlewareFunction`.
|
|
746
|
+
Middleware has no separate args-only type, so the full function type is the type argument. If you
|
|
747
|
+
omit the type argument, it defaults to React Router's own `MiddlewareFunction`:
|
|
748
|
+
|
|
749
|
+
```ts
|
|
750
|
+
import { Effect } from "effect";
|
|
751
|
+
import { makeMiddleware, Respond } from "../route.server.ts";
|
|
752
|
+
import type { Route } from "./+types/profile";
|
|
753
|
+
|
|
754
|
+
export const middleware: Route.MiddlewareFunction[] = [
|
|
755
|
+
makeMiddleware<Route.MiddlewareFunction>(({ request }, next) =>
|
|
756
|
+
Effect.gen(function* () {
|
|
757
|
+
const user = yield* getUser(request); // can fail with FormError / BadInputError
|
|
758
|
+
if (!user) yield* Respond.redirect("/login"); // throw → redirect, same as a loader
|
|
759
|
+
return yield* next(); // run the rest of the chain
|
|
760
|
+
}),
|
|
761
|
+
),
|
|
762
|
+
];
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
The effect gets `next` as `() => Effect.Effect<Result, FailureResponse>`. It is React Router's own
|
|
766
|
+
`next: () => Promise<Result>`, wrapped. Call it with `yield*` to run the downstream loaders and
|
|
767
|
+
middleware. A downstream throw, such as a nested loader's redirect, becomes a typed failure instead
|
|
768
|
+
of a rejected promise. It flows through the same recover and throw handling as a loader or action.
|
|
769
|
+
|
|
770
|
+
### Per-request services
|
|
771
|
+
|
|
772
|
+
Without the adapter, no router middleware runs before React Router. Set the context key in a plain
|
|
773
|
+
React Router middleware. The factory's `requestContext` reads it, and provides those services to the loader or
|
|
774
|
+
action. With the adapter, do not set the key this way, as
|
|
775
|
+
[Per-request services](#per-request-services) tells. This is the pattern without the adapter:
|
|
223
776
|
|
|
224
777
|
```ts
|
|
225
778
|
// app/request-context.server.ts
|
|
@@ -227,17 +780,13 @@ import { Context } from "effect";
|
|
|
227
780
|
import { createContext } from "react-router";
|
|
228
781
|
import type { RequestContextKey } from "react-router-effect";
|
|
229
782
|
|
|
230
|
-
class RequestContext extends Context.Service<
|
|
231
|
-
RequestContext,
|
|
232
|
-
|
|
233
|
-
readonly userId: string;
|
|
234
|
-
}
|
|
235
|
-
>()("app/RequestContext") {}
|
|
783
|
+
class RequestContext extends Context.Service<RequestContext, { readonly userId: string }>()(
|
|
784
|
+
"app/RequestContext",
|
|
785
|
+
) {}
|
|
236
786
|
|
|
237
|
-
// `requestContext` is a plain React Router context key — `createContext` is RR's own.
|
|
238
787
|
export const requestContext: RequestContextKey<RequestContext> = createContext();
|
|
239
788
|
|
|
240
|
-
// app/routes/profile.ts
|
|
789
|
+
// app/routes/profile.ts: a middleware sets a fresh value for each request.
|
|
241
790
|
export const middleware: Route.MiddlewareFunction[] = [
|
|
242
791
|
({ context, request }, next) => {
|
|
243
792
|
context.set(requestContext, Context.make(RequestContext, { userId: readUser(request) }));
|
|
@@ -245,13 +794,13 @@ export const middleware: Route.MiddlewareFunction[] = [
|
|
|
245
794
|
},
|
|
246
795
|
];
|
|
247
796
|
|
|
248
|
-
// app/route.server.ts
|
|
797
|
+
// app/route.server.ts: wire the same key into the factory.
|
|
249
798
|
export const { makeLoader } = makeEffectRouteFactory<DomainErrors>()(() => ({
|
|
250
799
|
runtime: appRuntime,
|
|
251
800
|
requestContext,
|
|
252
801
|
}));
|
|
253
802
|
|
|
254
|
-
//
|
|
803
|
+
// The loader requires RequestContext directly, with a fresh value for each request.
|
|
255
804
|
export const loader = makeLoader(() =>
|
|
256
805
|
Effect.gen(function* () {
|
|
257
806
|
const { userId } = yield* RequestContext;
|
|
@@ -260,26 +809,30 @@ export const loader = makeLoader(() =>
|
|
|
260
809
|
);
|
|
261
810
|
```
|
|
262
811
|
|
|
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
812
|
## API
|
|
268
813
|
|
|
269
|
-
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
-
|
|
279
|
-
`
|
|
280
|
-
-
|
|
281
|
-
|
|
282
|
-
|
|
814
|
+
- `makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond?, actionMeta? }))`
|
|
815
|
+
returns `{ makeLoader, makeAction, makeMiddleware, Respond, handlers }`. The config is a builder
|
|
816
|
+
that receives the base `Respond`. All its fields are optional. The returned `Respond` is the base
|
|
817
|
+
helpers merged with your `respond` extensions, and the base helpers win. `makeLoader` and
|
|
818
|
+
`makeAction` are the same wrapper. `makeMiddleware` wraps a `Route.MiddlewareFunction`, and
|
|
819
|
+
defaults to React Router's own `MiddlewareFunction`.
|
|
820
|
+
- `handlers` (`loader`, `action`, `middleware`, `requestContext`) is the runner that the server
|
|
821
|
+
adapter applies to every route module.
|
|
822
|
+
- `Respond` has `early` (recover), `throw` and `redirect`, and the `respond` helpers that you add.
|
|
823
|
+
- `ReturnableDataError`, `ThrowableDataError` and `ThrowableRedirectError` are the library route
|
|
824
|
+
errors. `isRouteError` narrows them.
|
|
825
|
+
- `ErrorHandler<Err>` is the handler signature type.
|
|
826
|
+
- `RequestContextKey<ReqServices>` is the type of the React Router context key for a per-request
|
|
827
|
+
effect context.
|
|
828
|
+
- `EffectRouteArgs<Args>`, `EffectMiddlewareArgs<Middleware>`, `RouteData<F>` and `Register` are the
|
|
829
|
+
types of plain Effect routes.
|
|
830
|
+
- `react-router-effect/server` (unstable) exports `ReactRouter`, with `ReactRouter.layer`, `ReactRouter.routes`
|
|
831
|
+
and the `ReactRouter.Options` type, and `ReactRouterAssets`, with `ReactRouterAssets.layer` and
|
|
832
|
+
`ReactRouterAssets.ClientBuildError`. It also exports the types `RouteEffectCheck` and
|
|
833
|
+
`RouteEffectFailures`.
|
|
834
|
+
- `react-router-effect/vite` (unstable) exports `ReactRouterVite.layer` and the `ReactRouterVite.ViteDevServer`
|
|
835
|
+
type, for development on Node. It needs `@effect/platform-node`.
|
|
283
836
|
|
|
284
837
|
## Development
|
|
285
838
|
|