react-router-effect 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +373 -117
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +160 -23
- package/dist/{route-data-MgSnnR33.d.mts → route-data-ByiuG-Yb.d.mts} +85 -26
- package/dist/server.d.mts +141 -75
- package/dist/server.mjs +286 -140
- package/dist/vite.d.mts +35 -14
- package/dist/vite.mjs +47 -13
- package/package.json +12 -7
- package/src/assets.ts +212 -0
- package/src/check.ts +9 -1
- package/src/defer.ts +179 -0
- package/src/factory.ts +161 -42
- package/src/index.ts +7 -3
- package/src/route-data.ts +9 -9
- package/src/{action-meta.ts → route-meta.ts} +19 -17
- package/src/server-entry.ts +2 -1
- package/src/server.ts +307 -295
- package/src/vite.ts +109 -32
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# react-router-effect
|
|
2
2
|
|
|
3
3
|
Serve a [React Router](https://reactrouter.com) framework-mode app from [Effect](https://effect.website)'s
|
|
4
|
-
|
|
4
|
+
HTTP router, on Node or on Cloudflare Workers. Write its loaders, actions and middleware as Effects.
|
|
5
5
|
|
|
6
6
|
A route function that returns an `Effect` runs with your app's services. When it fails with a tagged
|
|
7
7
|
error, the route factory you configure once decides what happens: recover with data for
|
|
@@ -24,17 +24,21 @@ If your app does not serve React Router from an Effect HTTP server, read
|
|
|
24
24
|
|
|
25
25
|
## Install
|
|
26
26
|
|
|
27
|
+
On Node:
|
|
28
|
+
|
|
27
29
|
```bash
|
|
28
|
-
vp add react-router-effect effect @effect/platform-node
|
|
30
|
+
vp add react-router-effect effect react-router @effect/platform-node
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
`effect`, `@effect/platform-node`
|
|
32
|
-
Effect 4.1 (`effect@^4.1.0`). Only `react-router-effect/vite` imports
|
|
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).
|
|
33
37
|
|
|
34
38
|
## Get started
|
|
35
39
|
|
|
36
|
-
[`examples/
|
|
37
|
-
Vite, tests against the production build, and benchmarks.
|
|
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.
|
|
38
42
|
|
|
39
43
|
### 1. Create the route factory
|
|
40
44
|
|
|
@@ -65,51 +69,59 @@ export const { Respond } = routeFactory;
|
|
|
65
69
|
|
|
66
70
|
### 2. Create the server app
|
|
67
71
|
|
|
68
|
-
`
|
|
69
|
-
|
|
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`.
|
|
70
76
|
|
|
71
77
|
```ts
|
|
72
78
|
// server/app.ts
|
|
73
|
-
import {
|
|
79
|
+
import { Layer } from "effect";
|
|
80
|
+
import { ReactRouter, ReactRouterAssets } from "react-router-effect/server";
|
|
74
81
|
|
|
75
82
|
import { routeFactory } from "../app/lib/route-factory.server";
|
|
76
83
|
|
|
77
|
-
export const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
);
|
|
82
93
|
```
|
|
83
94
|
|
|
84
95
|
### 3. Serve it
|
|
85
96
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
89
100
|
|
|
90
|
-
```
|
|
91
|
-
// server
|
|
101
|
+
```js
|
|
102
|
+
// server.js
|
|
92
103
|
import { createServer } from "node:http";
|
|
93
104
|
|
|
94
|
-
import
|
|
95
|
-
import
|
|
96
|
-
import {
|
|
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";
|
|
97
109
|
|
|
98
|
-
import {
|
|
110
|
+
import { App } from "./build/server/index.js";
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
).pipe(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
NodeRuntime.runMain(
|
|
113
|
+
Layer.launch(
|
|
114
|
+
HttpRouter.serve(App).pipe(
|
|
115
|
+
Layer.provide(NodeHttpServer.layer(() => createServer(), { port: 3000 })),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
106
118
|
);
|
|
107
119
|
```
|
|
108
120
|
|
|
109
121
|
### 4. Build the server
|
|
110
122
|
|
|
111
|
-
Make `server/
|
|
112
|
-
`
|
|
123
|
+
Make `server/app.ts` the entry of the server build. `react-router build` then writes `App` to
|
|
124
|
+
`build/server/index.js`.
|
|
113
125
|
|
|
114
126
|
```ts
|
|
115
127
|
// vite.config.ts
|
|
@@ -118,16 +130,17 @@ import { defineConfig } from "vite";
|
|
|
118
130
|
|
|
119
131
|
export default defineConfig({
|
|
120
132
|
plugins: [reactRouter()],
|
|
121
|
-
environments: { ssr: { build: { rollupOptions: { input: "./server/
|
|
133
|
+
environments: { ssr: { build: { rollupOptions: { input: "./server/app.ts" } } } },
|
|
122
134
|
});
|
|
123
135
|
```
|
|
124
136
|
|
|
125
137
|
```bash
|
|
126
138
|
react-router build
|
|
127
|
-
NODE_ENV=production node
|
|
139
|
+
NODE_ENV=production node server.js # React's server build reads NODE_ENV
|
|
128
140
|
```
|
|
129
141
|
|
|
130
|
-
For development,
|
|
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).
|
|
131
144
|
|
|
132
145
|
### 5. Write routes as Effects
|
|
133
146
|
|
|
@@ -164,6 +177,28 @@ middleware](#plain-effect-loaders-actions-and-middleware) explains why. A route
|
|
|
164
177
|
not return an Effect passes through to React Router unchanged, so promise loaders keep working in
|
|
165
178
|
the same app.
|
|
166
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
|
+
|
|
167
202
|
## Handle errors
|
|
168
203
|
|
|
169
204
|
An Effect can short-circuit in three ways, with the `Respond` helpers:
|
|
@@ -224,6 +259,79 @@ service-specific error that is not a declared domain error. Catch it or map it.
|
|
|
224
259
|
defaults for declared errors. The errors of one feature or service need explicit handling.
|
|
225
260
|
[The route check](#the-route-check) reports an unhandled error at compile time.
|
|
226
261
|
|
|
262
|
+
## Defer data
|
|
263
|
+
|
|
264
|
+
The route factory returns `defer`. It starts an Effect without waiting for it, and gives a promise
|
|
265
|
+
of the result. Put the promise in the loader data. React Router streams it to `<Await>` after the
|
|
266
|
+
first response. `defer` is unstable: a minor version can change it.
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
// app/lib/route-factory.server.ts
|
|
270
|
+
export const { Respond, defer } = routeFactory;
|
|
271
|
+
|
|
272
|
+
// app/routes/product.tsx
|
|
273
|
+
export const loader = Effect.fn("loader.product")(function* ({
|
|
274
|
+
params,
|
|
275
|
+
}: EffectRouteArgs<Route.LoaderArgs>) {
|
|
276
|
+
const product = yield* (yield* Products).get(params.id);
|
|
277
|
+
return {
|
|
278
|
+
product,
|
|
279
|
+
reviews: yield* defer((yield* Reviews).list(params.id)),
|
|
280
|
+
stats: yield* (yield* Stats).load(params.id).pipe(
|
|
281
|
+
Effect.catchTag("StatsUnavailable", () => Effect.succeed(null)),
|
|
282
|
+
defer,
|
|
283
|
+
),
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
export default function Product({ loaderData }: Route.ComponentProps) {
|
|
288
|
+
return (
|
|
289
|
+
<Suspense fallback={<p>Loading reviews</p>}>
|
|
290
|
+
<Await resolve={loaderData.reviews} errorElement={<ReviewsError />}>
|
|
291
|
+
{(reviews) => <ReviewList reviews={reviews} />}
|
|
292
|
+
</Await>
|
|
293
|
+
</Suspense>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The loader yields `defer`, and the deferred work starts at that point. The work and the rest of the
|
|
299
|
+
loader run at the same time. The work gets the services and the span of the loader, so its spans are in the trace of the request. It
|
|
300
|
+
keeps running after the loader returns. The work stops in two cases:
|
|
301
|
+
|
|
302
|
+
- The request aborts, for example because the client disconnects.
|
|
303
|
+
- The loader fails after it deferred the work. The response then has no data that waits for it.
|
|
304
|
+
|
|
305
|
+
A client-side navigation to another page does not abort the request. React Router aborts the request
|
|
306
|
+
of a navigation only while its loaders run. The work then runs until it ends, or until React
|
|
307
|
+
Router's `streamTimeout` ends the stream.
|
|
308
|
+
|
|
309
|
+
The response starts before the deferred work fails, so a failure cannot redirect or change the
|
|
310
|
+
response. The error handlers of the factory turn a failure into a value or a rejection:
|
|
311
|
+
|
|
312
|
+
| Failure of the deferred Effect | The promise |
|
|
313
|
+
| --------------------------------------------------------------- | ------------------------------------------------- |
|
|
314
|
+
| A handler recovers it, with `Respond.early` or `Effect.succeed` | resolves with the value → `<Await>` |
|
|
315
|
+
| `Respond.early(value)` raised directly | resolves with `value` → `<Await>` |
|
|
316
|
+
| A handler throws it, with `Respond.throw` or `Effect.fail` | rejects with an `ErrorResponse` → `useAsyncError` |
|
|
317
|
+
| `Respond.throw(value, init)` raised directly | rejects with an `ErrorResponse` → `useAsyncError` |
|
|
318
|
+
| A handler redirects, or a domain error has no handler | rejects with a plain `Error` |
|
|
319
|
+
| A defect or an interrupt | rejects with a plain `Error` |
|
|
320
|
+
|
|
321
|
+
The plain `Error` has the message `deferredFailureMessage` (`"Deferred route data failed"`) and no
|
|
322
|
+
stack, in every server mode. The server logs the full cause of a defect. A recovered
|
|
323
|
+
`Respond.early(value, init)` gives `value` without `init`, because a promise has no response.
|
|
324
|
+
|
|
325
|
+
The deferred Effect can fail only with the factory's domain errors, `Respond.early` and
|
|
326
|
+
`Respond.throw`. Any other error does not compile, and the message names the error tags. Handle
|
|
327
|
+
those errors before `defer`, or make them defects with `Effect.orDie`. In a factory without domain
|
|
328
|
+
errors, the deferred Effect must fail with `never`.
|
|
329
|
+
|
|
330
|
+
The promise type includes what the handlers recover, so `Route.ComponentProps` types the value that
|
|
331
|
+
`<Await>` renders. The type is `DeferredPromise<A>`, a subtype of `Promise<A>`. The
|
|
332
|
+
`promiseInEffectSuccess` rule of the Effect language service reports only the global `Promise`
|
|
333
|
+
type, so it does not report a `yield* defer(...)`.
|
|
334
|
+
|
|
227
335
|
## Provide services
|
|
228
336
|
|
|
229
337
|
### App-wide services with a runtime
|
|
@@ -255,19 +363,37 @@ makeEffectRouteFactory()(() => ({}));
|
|
|
255
363
|
The runtime provides app-wide services. A request-scoped service is the current user, a request id,
|
|
256
364
|
or a transaction for one request. There are two ways to provide one.
|
|
257
365
|
|
|
258
|
-
|
|
259
|
-
loader and action sees them, and no middleware has to set
|
|
366
|
+
A route-scoped router middleware on `ReactRouter.routes` provides services to React Router's
|
|
367
|
+
requests. Every middleware, loader and action sees them, and no React Router middleware has to set
|
|
368
|
+
them:
|
|
260
369
|
|
|
261
370
|
```ts
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
371
|
+
// server/app.ts
|
|
372
|
+
const RequestServices = HttpRouter.middleware((app) =>
|
|
373
|
+
Effect.provideServiceEffect(
|
|
374
|
+
app,
|
|
375
|
+
RequestInfo,
|
|
376
|
+
HttpServerRequest.HttpServerRequest.use((request) =>
|
|
377
|
+
Effect.succeed({
|
|
378
|
+
id: request.headers["x-request-id"] ?? crypto.randomUUID(),
|
|
379
|
+
startedAt: Date.now(),
|
|
380
|
+
}),
|
|
381
|
+
),
|
|
382
|
+
),
|
|
383
|
+
).layer;
|
|
384
|
+
|
|
385
|
+
export const App = Layer.mergeAll(
|
|
386
|
+
ReactRouter.routes.pipe(Layer.provide(RequestServices)),
|
|
387
|
+
ReactRouterAssets.layer,
|
|
388
|
+
).pipe(
|
|
389
|
+
Layer.provide(
|
|
390
|
+
ReactRouter.layer({
|
|
391
|
+
build: () => import("virtual:react-router/server-build"),
|
|
392
|
+
factory: routeFactory,
|
|
393
|
+
mode: import.meta.env.MODE,
|
|
269
394
|
}),
|
|
270
|
-
|
|
395
|
+
),
|
|
396
|
+
);
|
|
271
397
|
```
|
|
272
398
|
|
|
273
399
|
A [middleware](https://reactrouter.com/how-to/middleware) can also provide services. An effect
|
|
@@ -308,14 +434,26 @@ A route Effect can then require the runtime's services and the request's service
|
|
|
308
434
|
requirement is a compile error. `RequestContextKey<ReqServices>` is an alias of
|
|
309
435
|
`RouterContext<Context.Context<ReqServices>>`.
|
|
310
436
|
|
|
437
|
+
The adapter sets this key to the request fiber's context before React Router runs. That context
|
|
438
|
+
holds the server span, `HttpServerRequest` and the services of the router middleware. Do not set the
|
|
439
|
+
key to a new context in a React Router middleware, as an app without the adapter does. A new context
|
|
440
|
+
replaces those services for every function after that middleware. Provide request services with a
|
|
441
|
+
router middleware or an effect middleware, as this section shows.
|
|
442
|
+
|
|
311
443
|
## The adapter
|
|
312
444
|
|
|
313
|
-
`react-router-effect/server` is a React Router adapter for Effect's
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
445
|
+
`react-router-effect/server` is a React Router adapter for Effect's HTTP router, on every platform
|
|
446
|
+
that the router runs on. It is the Effect counterpart of `@react-router/express`. It
|
|
447
|
+
handles everything between an `HttpServerRequest` and React Router. That is the Web `Request` and
|
|
448
|
+
its abort signal, the route factory, and the client build. It also converts React Router's
|
|
449
|
+
`Response`, with its streamed body and its cookies. The app keeps its own server, middleware and
|
|
450
|
+
routes.
|
|
451
|
+
|
|
452
|
+
The adapter's API is unstable, and a minor version can change it. Every export of
|
|
453
|
+
`react-router-effect/server` and `react-router-effect/vite` has the JSDoc tag `@stability unstable`.
|
|
454
|
+
The route factory and the other exports of `react-router-effect` are stable, except the route
|
|
455
|
+
metadata. The `routeMeta` configuration, the `Register` key `routeMeta`, `RouteMetaArgs`,
|
|
456
|
+
`WithRouteMeta` and `routeMetaKey` are unstable, and a minor version can change them.
|
|
319
457
|
|
|
320
458
|
React Router keeps doing what it does for any other server adapter: documents, `.data` requests,
|
|
321
459
|
streamed promises, resource routes, the lazy route manifest and middleware. Before React Router
|
|
@@ -325,66 +463,165 @@ sees the build, the adapter runs every loader, action and middleware through you
|
|
|
325
463
|
Its failures go through the factory's error handlers.
|
|
326
464
|
- Any other route function passes through. Promise loaders and `makeLoader` wrappers keep working.
|
|
327
465
|
|
|
328
|
-
The
|
|
329
|
-
|
|
330
|
-
- `
|
|
331
|
-
- `
|
|
332
|
-
- `
|
|
333
|
-
- `
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
466
|
+
The layers:
|
|
467
|
+
|
|
468
|
+
- `ReactRouter.layer(options)` provides the `ReactRouter` service. Its options are these:
|
|
469
|
+
- `build` is the server build, or a function that loads it.
|
|
470
|
+
- `factory` is the route factory.
|
|
471
|
+
- `mode` is React Router's server mode, and you must give it. In a Vite app, pass
|
|
472
|
+
`import.meta.env.MODE`. React Router runs in production mode for any mode but `development`
|
|
473
|
+
and `test`, and so does the layer. At layer build, the layer loads a build value once, or a
|
|
474
|
+
build function in production mode. In `development` and `test`, a build function loads for
|
|
475
|
+
each request.
|
|
476
|
+
- `loadContext` makes React Router's load context for each request. Read
|
|
477
|
+
[The load context](#the-load-context).
|
|
478
|
+
- `ReactRouter.routes` registers React Router as the catch-all route on `*`.
|
|
479
|
+
- `ReactRouterAssets.layer` registers the files of the client build. Read
|
|
480
|
+
[The client build](#the-client-build).
|
|
481
|
+
- `ReactRouterVite.layer`, from `react-router-effect/vite`, serves the app in development on Node.
|
|
482
|
+
Read [Development with Vite](examples/node/README.md#development-with-vite).
|
|
483
|
+
|
|
484
|
+
Provide one value of `ReactRouter.layer` to every layer that reads the service, as `App` does with
|
|
485
|
+
`Layer.provide`. Two values load the build twice.
|
|
486
|
+
|
|
487
|
+
### The load context
|
|
488
|
+
|
|
489
|
+
`loadContext` does for the adapter what `getLoadContext` does for `@react-router/express`. It
|
|
490
|
+
returns an Effect of a `RouterContextProvider`. Plain loaders and actions, and `handleRequest` in
|
|
491
|
+
`entry.server.tsx`, read its values with `context.get`. Route Effects read services from their
|
|
492
|
+
Effect context. So give route Effects services with router middleware, not with `loadContext`.
|
|
354
493
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
494
|
+
```ts
|
|
495
|
+
ReactRouter.layer({
|
|
496
|
+
build: () => import("virtual:react-router/server-build"),
|
|
497
|
+
factory: routeFactory,
|
|
498
|
+
mode: import.meta.env.MODE,
|
|
499
|
+
loadContext: (request) =>
|
|
500
|
+
Effect.sync(() => {
|
|
501
|
+
const context = new RouterContextProvider();
|
|
502
|
+
context.set(localeKey, request.headers["accept-language"] ?? "en");
|
|
503
|
+
return context;
|
|
504
|
+
}),
|
|
505
|
+
});
|
|
506
|
+
```
|
|
358
507
|
|
|
359
|
-
|
|
508
|
+
The Effect gets the services of the layer and the services of the request. The services of the
|
|
509
|
+
request, such as its scope, win. The adapter adds its own keys to the provider. If the Effect dies,
|
|
510
|
+
the client gets an empty 500.
|
|
360
511
|
|
|
361
|
-
|
|
362
|
-
the app, on Effect's Node HTTP server:
|
|
512
|
+
### Routes and middleware
|
|
363
513
|
|
|
364
|
-
|
|
365
|
-
|
|
514
|
+
The route that a request matches decides which middleware runs on it. Effect has three kinds of
|
|
515
|
+
middleware:
|
|
366
516
|
|
|
367
|
-
|
|
368
|
-
|
|
517
|
+
- A server middleware, the `middleware` option of `HttpRouter.serve`, wraps every request. It also
|
|
518
|
+
wraps the send of the response, so a change to the response with `Effect.map` does not reach the
|
|
519
|
+
client. `HttpMiddleware.compression` works there.
|
|
520
|
+
- A global router middleware, `HttpRouter.middleware(fn, { global: true })`, wraps every route.
|
|
521
|
+
- A route-scoped middleware, `HttpRouter.middleware(fn).layer`, applies only to the routes of the
|
|
522
|
+
layers that you provide it to.
|
|
523
|
+
|
|
524
|
+
Route-scoped middleware selects the middleware of each kind of request. `HttpRouter.serve` logs
|
|
525
|
+
every request. To leave file requests out of that log, provide `HttpRouter.disableLogger` to the
|
|
526
|
+
assets layer only:
|
|
527
|
+
|
|
528
|
+
```ts
|
|
529
|
+
export const App = Layer.mergeAll(
|
|
530
|
+
ReactRouter.routes.pipe(Layer.provide(RequestServices)),
|
|
531
|
+
ReactRouterAssets.layer.pipe(Layer.provide(HttpRouter.disableLogger)),
|
|
532
|
+
HttpRouter.middleware(HttpMiddleware.compression(), { global: true }),
|
|
533
|
+
).pipe(
|
|
534
|
+
Layer.provide(
|
|
535
|
+
ReactRouter.layer({
|
|
536
|
+
build: () => import("virtual:react-router/server-build"),
|
|
537
|
+
factory: routeFactory,
|
|
538
|
+
mode: import.meta.env.MODE,
|
|
539
|
+
}),
|
|
540
|
+
),
|
|
541
|
+
);
|
|
369
542
|
```
|
|
370
543
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
544
|
+
The order of two route-scoped middleware layers on one route is not fixed. If the order matters,
|
|
545
|
+
join them into one middleware with `combine`:
|
|
546
|
+
`HttpRouter.middleware(first).combine(HttpRouter.middleware(second)).layer`.
|
|
547
|
+
|
|
548
|
+
These rules apply to the routes:
|
|
549
|
+
|
|
550
|
+
- An exact route wins over a route with a parameter, and both win over a wildcard. So your own
|
|
551
|
+
routes and the files of the client build win over React Router's `*`.
|
|
552
|
+
- A route of your own on `*` or `/*`, for a method that React Router's route has, collides with it.
|
|
553
|
+
Then the server fails to start. A route of your own with the path of a file of the client build
|
|
554
|
+
fails the same way.
|
|
555
|
+
- If a HEAD request matches no HEAD route, it uses the GET route of its path. `ReactRouter.routes`
|
|
556
|
+
registers no HEAD route. So a GET route of your own answers HEAD, as it does in Express.
|
|
557
|
+
- React Router's route takes the methods of `HttpMethod.all`: GET, POST, PUT, DELETE, PATCH,
|
|
558
|
+
OPTIONS, TRACE and QUERY, and HEAD through GET. A request with another method, such as PROPFIND,
|
|
559
|
+
gets a 404 from the router.
|
|
560
|
+
- Register `ReactRouter.routes` at the root of the router. Under a prefix, React Router does not
|
|
561
|
+
get the path that it expects. To serve the app under a path, set React Router's `basename`.
|
|
562
|
+
- The router ignores the case of the path. On a file system that is case-sensitive, a request for a
|
|
563
|
+
file with the wrong case matches the file's route and gets a 404.
|
|
564
|
+
|
|
565
|
+
### The client build
|
|
566
|
+
|
|
567
|
+
`ReactRouterAssets.layer` serves `build.assetsBuildDirectory` under `build.publicPath`, as the two
|
|
568
|
+
`express.static` lines of React Router's Express template do. It needs the platform's file system,
|
|
569
|
+
which `NodeHttpServer.layer` provides. A Cloudflare Worker serves the client build from its static
|
|
570
|
+
assets, and does not use this layer.
|
|
571
|
+
|
|
572
|
+
- The hashed directory is the directory of the build's manifest URL, such as `/assets`. React
|
|
573
|
+
Router's build writes files with a content hash in their names there. The layer caches them for a
|
|
574
|
+
year as immutable. A request under the hashed directory gets a 404 for a file that the directory
|
|
575
|
+
does not have. A path with `..` does not get out of the hashed directory.
|
|
576
|
+
- Each other file gets its own route for GET and HEAD, and the layer caches it for an hour. At
|
|
577
|
+
server start, the layer reads the client build. If a file is added outside the hashed directory
|
|
578
|
+
later, the layer serves it only after a restart.
|
|
579
|
+
- A directory with an `index.html`, such as a prerendered page, serves that file. Its path without
|
|
580
|
+
the trailing slash redirects to the path with it. A root `index.html`, such as a prerendered home
|
|
581
|
+
page, serves `/`, and React Router's catch-all yields to it.
|
|
582
|
+
- If the manifest URL is not under `publicPath`, or it is at the root of `publicPath`, there is no
|
|
583
|
+
hashed directory. Then the layer caches every file for an hour.
|
|
584
|
+
- The layer serves no dotfiles, such as `.vite/manifest.json`, as `express.static` does.
|
|
585
|
+
- The layer registers nothing for a build that loads for each request, such as a development build.
|
|
586
|
+
Vite serves the client there. It also registers nothing for a `publicPath` on another origin.
|
|
587
|
+
- `assetsBuildDirectory` is relative to the working directory of the server. If the layer cannot
|
|
588
|
+
read it, the server fails to start with a `ClientBuildError` that names the resolved directory.
|
|
589
|
+
If a file name has a `*`, the server fails to start with a `ClientBuildError` too, because the
|
|
590
|
+
router cannot match that name.
|
|
591
|
+
|
|
592
|
+
### Requests
|
|
593
|
+
|
|
594
|
+
The Web request comes from `HttpServerRequest.toWeb`, body included. On Effect's Node server, the
|
|
595
|
+
request has the changes that middleware makes to the `HttpServerRequest`, such as the headers of
|
|
596
|
+
`HttpMiddleware.xForwardedHeaders`. On a Web server, such as a Cloudflare Worker, React Router gets
|
|
597
|
+
the runtime's own request. The README of each example describes the requests of its platform.
|
|
598
|
+
|
|
599
|
+
If the client leaves while React Router runs or while the body streams, the response ends early, and
|
|
600
|
+
the request signal aborts. A completed response never aborts it. On Cloudflare Workers, the abort
|
|
601
|
+
comes from Cloudflare's own request signal. For the conditions of that abort, read the
|
|
602
|
+
[Cloudflare example](examples/cloudflare/README.md#requests).
|
|
603
|
+
|
|
604
|
+
React Router logs and answers the errors of route code. If the adapter's own steps fail, for example
|
|
605
|
+
a `loadContext` that dies, the adapter logs the error with `Effect.logError`. Then it answers an
|
|
606
|
+
empty 500. The log goes to the loggers of the app's Effect runtime.
|
|
375
607
|
|
|
376
608
|
### Cookies
|
|
377
609
|
|
|
378
610
|
Set cookies from route code with React Router's cookie helpers, `createCookie` and the session
|
|
379
611
|
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.
|
|
381
|
-
|
|
382
|
-
none of them.
|
|
612
|
+
the response's `cookies`, as `HttpServerResponse.fromWeb` does. A router middleware on
|
|
613
|
+
`ReactRouter.routes` can read and change them like any other cookie. A response that replaces React
|
|
614
|
+
Router's has none of them.
|
|
383
615
|
|
|
384
616
|
Effect keeps one cookie per name and only the attributes it knows: `Max-Age`, `Domain`, `Path`,
|
|
385
|
-
`Expires`, `HttpOnly`, `Secure`, `Partitioned`, `Priority` and `SameSite`.
|
|
386
|
-
same cookie name twice, for example to expire it on one path and set it on another
|
|
387
|
-
|
|
617
|
+
`Expires`, `HttpOnly`, `Secure`, `Partitioned`, `Priority` and `SameSite`. One response can set the
|
|
618
|
+
same cookie name twice, for example to expire it on one path and set it on another. Then Effect
|
|
619
|
+
sends only the last one.
|
|
620
|
+
|
|
621
|
+
Effect accepts only cookie names that are RFC 6265 tokens: letters, digits and
|
|
622
|
+
``!#$%&'*+-.^_`|~``. React Router's `createCookie` and session storages also accept other names,
|
|
623
|
+
such as `app/session` or `user:prefs`. The adapter does not send a cookie with such a name. It logs
|
|
624
|
+
an error with the cookie's name instead. Give every cookie a token name.
|
|
388
625
|
|
|
389
626
|
### Content type
|
|
390
627
|
|
|
@@ -401,10 +638,15 @@ return new Response(Readable.toWeb(file) as ReadableStream, {
|
|
|
401
638
|
|
|
402
639
|
### Tracing
|
|
403
640
|
|
|
404
|
-
When the request has a server span, the adapter traces React Router.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
641
|
+
When the request has a server span, the adapter traces React Router. If the app provides a tracer
|
|
642
|
+
that exports spans, such as `OtelTracer`, Effect's HTTP server makes the server span. Give the
|
|
643
|
+
tracer where the serve function reads it, as [Platforms](#platforms) tells. Effect serves each
|
|
644
|
+
request with the services of that layer. A tracer that the serve function does not read makes no
|
|
645
|
+
server span, so the adapter adds no spans. The README of each example shows where its tracer goes.
|
|
646
|
+
|
|
647
|
+
Each middleware, loader and action runs in a span of its own, such as
|
|
648
|
+
`react-router.loader routes/todos`.
|
|
649
|
+
The spans nest as React Router calls the functions. This is the example's `GET /todos`:
|
|
408
650
|
|
|
409
651
|
```text
|
|
410
652
|
GET the server span, with http.route=/todos
|
|
@@ -423,6 +665,12 @@ instrumentation traces nest there too, such as a database driver that a promise
|
|
|
423
665
|
Without a server span, or with Effect's native tracer, whose spans go nowhere, the adapter adds no
|
|
424
666
|
spans and no cost.
|
|
425
667
|
|
|
668
|
+
The adapter sets `http.route` on the server span to the pattern of the matched routes, such as
|
|
669
|
+
`/todos/:id`, once per request. It does this also for a page that runs no route function. A router
|
|
670
|
+
middleware that opens a span of its own does not change that: the pattern still goes on the server
|
|
671
|
+
span. A request that matches no route keeps the router's `*`. `ReactRouter.layer` reads the tracer
|
|
672
|
+
once, at layer build. So it does not see a tracer that the app provides only inside a request.
|
|
673
|
+
|
|
426
674
|
## Route functions
|
|
427
675
|
|
|
428
676
|
### Plain Effect loaders, actions and middleware
|
|
@@ -455,10 +703,10 @@ export const middleware = [
|
|
|
455
703
|
`Route.ComponentProps` types the data of an Effect loader or action, including what the factory
|
|
456
704
|
recovers.
|
|
457
705
|
- Hooks that take `typeof loader` or `typeof action`, such as `useFetcher<typeof action>()`,
|
|
458
|
-
read the data through React Router's `SerializeFrom`.
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
members, so for an Effect the hook
|
|
706
|
+
read the data through React Router's `SerializeFrom`. If the argument of a function has the
|
|
707
|
+
members of client loader args, `SerializeFrom` treats it as a client function. It gives a
|
|
708
|
+
client function its raw return value. Typegen's `Route.LoaderArgs` and `Route.ActionArgs` have
|
|
709
|
+
those same members, so for an Effect the hook types the data as the Effect itself.
|
|
462
710
|
- Take the args through `EffectRouteArgs<Route.LoaderArgs>`. It makes `pattern` optional, so the
|
|
463
711
|
argument no longer matches and the hook reads the brand. A loader without args needs nothing.
|
|
464
712
|
- `RouteData<typeof loader>` reads the brand directly and applies React Router's serialization.
|
|
@@ -468,8 +716,8 @@ export const middleware = [
|
|
|
468
716
|
and the span that it provides around `next()` reach every route Effect downstream. `context.set`
|
|
469
717
|
keeps working. You cannot annotate a route array with an effect middleware as
|
|
470
718
|
`Route.MiddlewareFunction[]`.
|
|
471
|
-
-
|
|
472
|
-
adapter attaches it to
|
|
719
|
+
- Route metadata: `routeMeta` in the factory configuration is read after every loader and action
|
|
720
|
+
resolves. The adapter attaches it to their data under `__meta`.
|
|
473
721
|
- Tests: `routeFactory.handlers.loader(loader)(args)` runs a route function as the server does.
|
|
474
722
|
|
|
475
723
|
### Register what the factory recovers
|
|
@@ -480,7 +728,7 @@ A plain Effect loader cannot see the factory's handlers. Declare what they recov
|
|
|
480
728
|
declare module "react-router-effect" {
|
|
481
729
|
interface Register {
|
|
482
730
|
recover: { FormValidationError: { reply: SubmissionResponse } };
|
|
483
|
-
|
|
731
|
+
routeMeta: { realtimeEventIds: ReadonlyArray<string> };
|
|
484
732
|
}
|
|
485
733
|
}
|
|
486
734
|
```
|
|
@@ -510,8 +758,10 @@ Type '{}' is missing the following properties from type ...:
|
|
|
510
758
|
|
|
511
759
|
### Requirements and limits
|
|
512
760
|
|
|
513
|
-
- Effect 4.1 or later,
|
|
514
|
-
|
|
761
|
+
- Effect 4.1 or later, and `skipLibCheck`. The brand augments `Effect`, and TypeScript cannot test
|
|
762
|
+
the variance annotations of that augmentation.
|
|
763
|
+
- For the adapter, React Router 8.3 or later. The route factory, `makeLoader`, `makeAction` and
|
|
764
|
+
`makeMiddleware` also support React Router 7.16 or later.
|
|
515
765
|
- A route module export must be a function. A bare `Effect` value is not a loader.
|
|
516
766
|
- Prerendering (`prerender`) and RSC mode call the build without the adapter.
|
|
517
767
|
|
|
@@ -594,9 +844,10 @@ of a rejected promise. It flows through the same recover and throw handling as a
|
|
|
594
844
|
|
|
595
845
|
### Per-request services
|
|
596
846
|
|
|
597
|
-
Without the adapter
|
|
598
|
-
middleware. The factory's `requestContext` reads it, and provides those services to the loader or
|
|
599
|
-
action
|
|
847
|
+
Without the adapter, no router middleware runs before React Router. Set the context key in a plain
|
|
848
|
+
React Router middleware. The factory's `requestContext` reads it, and provides those services to the loader or
|
|
849
|
+
action. With the adapter, do not set the key this way, as
|
|
850
|
+
[Per-request services](#per-request-services) tells. This is the pattern without the adapter:
|
|
600
851
|
|
|
601
852
|
```ts
|
|
602
853
|
// app/request-context.server.ts
|
|
@@ -635,8 +886,8 @@ export const loader = makeLoader(() =>
|
|
|
635
886
|
|
|
636
887
|
## API
|
|
637
888
|
|
|
638
|
-
- `makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond?,
|
|
639
|
-
returns `{ makeLoader, makeAction, makeMiddleware, Respond, handlers }`. The config is a builder
|
|
889
|
+
- `makeEffectRouteFactory<DomainErrors>()((Respond) => ({ errorHandlers?, runtime?, requestContext?, respond?, routeMeta? }))`
|
|
890
|
+
returns `{ makeLoader, makeAction, makeMiddleware, Respond, defer, handlers }`. The config is a builder
|
|
640
891
|
that receives the base `Respond`. All its fields are optional. The returned `Respond` is the base
|
|
641
892
|
helpers merged with your `respond` extensions, and the base helpers win. `makeLoader` and
|
|
642
893
|
`makeAction` are the same wrapper. `makeMiddleware` wraps a `Route.MiddlewareFunction`, and
|
|
@@ -644,6 +895,9 @@ export const loader = makeLoader(() =>
|
|
|
644
895
|
- `handlers` (`loader`, `action`, `middleware`, `requestContext`) is the runner that the server
|
|
645
896
|
adapter applies to every route module.
|
|
646
897
|
- `Respond` has `early` (recover), `throw` and `redirect`, and the `respond` helpers that you add.
|
|
898
|
+
- `defer` (unstable) starts an Effect and gives a `DeferredPromise` of its result for loader data.
|
|
899
|
+
Read [Defer data](#defer-data). The types `DeferredPromise`, `DeferDiagnose`, `DeferHandled` and
|
|
900
|
+
`DeferRecoverOf`, and the constant `deferredFailureMessage`, go with it.
|
|
647
901
|
- `ReturnableDataError`, `ThrowableDataError` and `ThrowableRedirectError` are the library route
|
|
648
902
|
errors. `isRouteError` narrows them.
|
|
649
903
|
- `ErrorHandler<Err>` is the handler signature type.
|
|
@@ -651,10 +905,12 @@ export const loader = makeLoader(() =>
|
|
|
651
905
|
effect context.
|
|
652
906
|
- `EffectRouteArgs<Args>`, `EffectMiddlewareArgs<Middleware>`, `RouteData<F>` and `Register` are the
|
|
653
907
|
types of plain Effect routes.
|
|
654
|
-
- `react-router-effect/server` exports `
|
|
908
|
+
- `react-router-effect/server` (unstable) exports `ReactRouter`, with `ReactRouter.layer`, `ReactRouter.routes`
|
|
909
|
+
and the `ReactRouter.Options` type, and `ReactRouterAssets`, with `ReactRouterAssets.layer` and
|
|
910
|
+
`ReactRouterAssets.ClientBuildError`. It also exports the types `RouteEffectCheck` and
|
|
655
911
|
`RouteEffectFailures`.
|
|
656
|
-
- `react-router-effect/vite` exports `ReactRouterVite.
|
|
657
|
-
`@effect/platform-node`.
|
|
912
|
+
- `react-router-effect/vite` (unstable) exports `ReactRouterVite.layer` and the `ReactRouterVite.ViteDevServer`
|
|
913
|
+
type, for development on Node. It needs `@effect/platform-node`.
|
|
658
914
|
|
|
659
915
|
## Development
|
|
660
916
|
|