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/src/server.ts
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves a React Router framework-mode build from Effect's HTTP server.
|
|
3
|
+
*
|
|
4
|
+
* The adapter converts each `HttpServerRequest` into a Web `Request`, calls
|
|
5
|
+
* React Router's own request handler, and streams the `Response` back. React
|
|
6
|
+
* Router keeps doing everything it does for any other server adapter:
|
|
7
|
+
* documents, `.data` requests, streamed promises, resource routes, the lazy
|
|
8
|
+
* route manifest, and middleware.
|
|
9
|
+
*
|
|
10
|
+
* The adapter owns everything between the Effect HTTP request and React
|
|
11
|
+
* Router: the Web `Request` and its abort signal, the route factory, and the
|
|
12
|
+
* conversion of React Router's `Response`, cookies and streamed body included.
|
|
13
|
+
* Listening, static files, compression, proxies, tracing and the other server
|
|
14
|
+
* concerns stay with the app's Effect HTTP server.
|
|
15
|
+
*
|
|
16
|
+
* Before React Router sees the build, the adapter wraps every loader, action
|
|
17
|
+
* and middleware with the route factory's runner. A route function that
|
|
18
|
+
* returns an Effect runs through the factory's error model, with the factory's
|
|
19
|
+
* runtime and the request's effect context. Any other route function passes
|
|
20
|
+
* through unchanged, so existing promise loaders and `makeLoader` wrappers keep
|
|
21
|
+
* working.
|
|
22
|
+
*
|
|
23
|
+
* The adapter's API is unstable: a minor version can change it.
|
|
24
|
+
*
|
|
25
|
+
* @stability unstable
|
|
26
|
+
* @since 2.1.0
|
|
27
|
+
*/
|
|
28
|
+
import {
|
|
29
|
+
Array as Arr,
|
|
30
|
+
Cause,
|
|
31
|
+
Context,
|
|
32
|
+
Effect,
|
|
33
|
+
Exit,
|
|
34
|
+
Layer,
|
|
35
|
+
Option,
|
|
36
|
+
Predicate,
|
|
37
|
+
Record,
|
|
38
|
+
Scope,
|
|
39
|
+
Stream,
|
|
40
|
+
Struct,
|
|
41
|
+
Tracer,
|
|
42
|
+
} from "effect";
|
|
43
|
+
import {
|
|
44
|
+
Cookies,
|
|
45
|
+
HttpMethod,
|
|
46
|
+
HttpRouter,
|
|
47
|
+
HttpServerError,
|
|
48
|
+
HttpServerRequest,
|
|
49
|
+
HttpServerResponse,
|
|
50
|
+
} from "effect/http";
|
|
51
|
+
import {
|
|
52
|
+
createContext,
|
|
53
|
+
createRequestHandler,
|
|
54
|
+
RouterContextProvider,
|
|
55
|
+
type RouterContext,
|
|
56
|
+
type ServerBuild,
|
|
57
|
+
type ServerInstrumentation,
|
|
58
|
+
type MiddlewareFunction,
|
|
59
|
+
} from "react-router";
|
|
60
|
+
|
|
61
|
+
import type { RouteHandlers } from "./factory.ts";
|
|
62
|
+
|
|
63
|
+
/** A React Router server build, or a function that loads it, as in `createRequestHandler`. */
|
|
64
|
+
type BuildSource = ServerBuild | (() => ServerBuild | Promise<ServerBuild>);
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Response conversion.
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Converts React Router's response. Its `Set-Cookie` values become the
|
|
72
|
+
* response's cookies, as `HttpServerResponse.fromWeb` converts them, so
|
|
73
|
+
* middleware after the adapter can read and change them. Effect keeps one
|
|
74
|
+
* cookie per name, the last one, and only the attributes it knows. A cookie
|
|
75
|
+
* whose name Effect's parser rejects is dropped, and `handleRequest` logs it.
|
|
76
|
+
*
|
|
77
|
+
* Effect gives a streamed body without a content type
|
|
78
|
+
* `application/octet-stream`, so a route that sends a body sets its own.
|
|
79
|
+
*
|
|
80
|
+
* When the body stream ends before it finished, because the client left or a
|
|
81
|
+
* write failed, the request signal aborts: React Router then stops streamed
|
|
82
|
+
* promises, and route code sees the abort. A completed response never aborts it.
|
|
83
|
+
*/
|
|
84
|
+
const toHttpResponse = (
|
|
85
|
+
response: Response,
|
|
86
|
+
cookies: Cookies.Cookies,
|
|
87
|
+
controller: AbortController,
|
|
88
|
+
): HttpServerResponse.HttpServerResponse => {
|
|
89
|
+
const headers = new Headers(response.headers);
|
|
90
|
+
headers.delete("set-cookie");
|
|
91
|
+
// React Router leaves `statusText` empty; Node then sends its standard reason phrase.
|
|
92
|
+
const init = {
|
|
93
|
+
status: response.status,
|
|
94
|
+
statusText: response.statusText === "" ? undefined : response.statusText,
|
|
95
|
+
headers,
|
|
96
|
+
cookies,
|
|
97
|
+
};
|
|
98
|
+
const body = response.body;
|
|
99
|
+
return body === null
|
|
100
|
+
? HttpServerResponse.empty(init)
|
|
101
|
+
: HttpServerResponse.stream(
|
|
102
|
+
Stream.fromReadableStream({ evaluate: () => body, onError: (error) => error }).pipe(
|
|
103
|
+
Stream.onExit((exit) =>
|
|
104
|
+
Exit.isSuccess(exit) ? Effect.void : Effect.sync(() => controller.abort()),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
init,
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The names of the `Set-Cookie` values that are not in `cookies`, because
|
|
113
|
+
* Effect's parser rejects them, such as a name with `/` or `:`. A value whose
|
|
114
|
+
* name another value repeats is not dropped: Effect keeps the last cookie of
|
|
115
|
+
* each name. Each value is parsed alone only when the counts differ.
|
|
116
|
+
*/
|
|
117
|
+
const droppedCookies = (
|
|
118
|
+
values: ReadonlyArray<string>,
|
|
119
|
+
cookies: Cookies.Cookies,
|
|
120
|
+
): ReadonlyArray<string> =>
|
|
121
|
+
Object.keys(cookies.cookies).length === values.length
|
|
122
|
+
? []
|
|
123
|
+
: values
|
|
124
|
+
.filter((value) => Cookies.isEmpty(Cookies.fromSetCookie(value)))
|
|
125
|
+
// The name, for the log only. The value can hold a secret.
|
|
126
|
+
.map((value) => value.split(/[=;]/, 1)[0]?.trim() ?? "");
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Tracing.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The server span of a traced request, which gets `http.route`. Without it,
|
|
134
|
+
* the adapter adds no spans. The `react-router.*` spans nest under the
|
|
135
|
+
* request fiber's own span instead, through the request store.
|
|
136
|
+
*/
|
|
137
|
+
const requestSpanKey: RouterContext<Tracer.Span | undefined> = createContext<
|
|
138
|
+
Tracer.Span | undefined
|
|
139
|
+
>(undefined);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The server span of the request, when a tracer exports it: not the native
|
|
143
|
+
* tracer, whose spans go nowhere, and not an unsampled span. A route-scoped
|
|
144
|
+
* router middleware can open spans of its own between the request fiber's
|
|
145
|
+
* span and the server span, so the search goes up to the span of kind
|
|
146
|
+
* `server` that Effect's HTTP tracer opens. It runs once per request. The
|
|
147
|
+
* search goes by kind: an app's own span of kind `server` inside the request
|
|
148
|
+
* would get `http.route` instead.
|
|
149
|
+
*/
|
|
150
|
+
const serverSpan = (context: Context.Context<never>): Tracer.Span | undefined => {
|
|
151
|
+
if (Context.get(context, Tracer.Tracer) === Tracer.nativeTracer) return undefined;
|
|
152
|
+
let span = Context.getOrUndefined(context, Tracer.ParentSpan);
|
|
153
|
+
while (Predicate.isTagged(span, "Span") && span.kind !== "server") {
|
|
154
|
+
span = Option.getOrUndefined(span.parent);
|
|
155
|
+
}
|
|
156
|
+
return Predicate.isTagged(span, "Span") && span.sampled ? span : undefined;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The `http.route` of a routed pattern, such as `/posts/:id`. React Router
|
|
161
|
+
* joins the route paths, so a root route without a path leaves out the
|
|
162
|
+
* leading slash.
|
|
163
|
+
*/
|
|
164
|
+
const httpRoute = (pattern: string): string => (pattern.startsWith("/") ? pattern : `/${pattern}`);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Sets `http.route` on the server span to the pattern of the routes that
|
|
168
|
+
* React Router matched, once per request, after React Router answered. React
|
|
169
|
+
* Router 8 gives the pattern to this request handler hook, also for a page
|
|
170
|
+
* that runs no route function. A request that matched no route keeps the
|
|
171
|
+
* router's `*`.
|
|
172
|
+
*/
|
|
173
|
+
const routeLabel: ServerInstrumentation = {
|
|
174
|
+
handler(handler) {
|
|
175
|
+
handler.instrument({
|
|
176
|
+
async request(handleRequest, { context }) {
|
|
177
|
+
const { meta } = await handleRequest();
|
|
178
|
+
const span = context?.get(requestSpanKey);
|
|
179
|
+
if (span !== undefined && meta !== undefined && meta.pattern !== "") {
|
|
180
|
+
span.attribute("http.route", httpRoute(meta.pattern));
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** What a route function produced: its value, or what it threw. */
|
|
188
|
+
type Settled =
|
|
189
|
+
| { readonly ok: true; readonly value: unknown }
|
|
190
|
+
| { readonly ok: false; readonly error: unknown };
|
|
191
|
+
|
|
192
|
+
/** Calls a route function and captures its result, a synchronous throw included. */
|
|
193
|
+
const settle = (call: () => unknown): Promise<Settled> => {
|
|
194
|
+
try {
|
|
195
|
+
return Promise.resolve(call()).then(
|
|
196
|
+
(value): Settled => ({ ok: true, value }),
|
|
197
|
+
(error: unknown): Settled => ({ ok: false, error }),
|
|
198
|
+
);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return Promise.resolve({ ok: false, error });
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** The status of a thrown `Response` or `data(...)`, which React Router sends as a response. */
|
|
205
|
+
const thrownStatus = (error: unknown): number | undefined => {
|
|
206
|
+
if (error instanceof Response) return error.status;
|
|
207
|
+
if (typeof error !== "object" || error === null || !("type" in error)) return undefined;
|
|
208
|
+
if (error.type !== "DataWithResponseInit" || !("init" in error)) return undefined;
|
|
209
|
+
const init = error.init;
|
|
210
|
+
return typeof init === "object" &&
|
|
211
|
+
init !== null &&
|
|
212
|
+
"status" in init &&
|
|
213
|
+
typeof init.status === "number"
|
|
214
|
+
? init.status
|
|
215
|
+
: 200;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A thrown value is an error of the span unless React Router sends it as a
|
|
220
|
+
* response below 500, such as a redirect or `data(..., 404)`.
|
|
221
|
+
*/
|
|
222
|
+
const isSpanError = (error: unknown): boolean => {
|
|
223
|
+
const status = thrownStatus(error);
|
|
224
|
+
return status === undefined || status >= 500;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** What every route function gets, as far as tracing reads it. */
|
|
228
|
+
interface TracedArgs {
|
|
229
|
+
readonly context: Readonly<RouterContextProvider>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The kind of route function, as React Router names it. */
|
|
233
|
+
type Operation = "middleware" | "loader" | "action";
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Runs a route function in a span of its own when the request is traced,
|
|
237
|
+
* such as `react-router.loader routes/home`. `routeLabel` sets `http.route`.
|
|
238
|
+
*
|
|
239
|
+
* The spans follow React Router's structure. A middleware's span is the
|
|
240
|
+
* parent of everything its `next()` runs: the next middleware, then the
|
|
241
|
+
* loaders and the action. A route Effect nests under its function's span, and
|
|
242
|
+
* so do the clients an OpenTelemetry instrumentation traces, since Effect's
|
|
243
|
+
* OpenTelemetry tracer makes the span active while the function is called.
|
|
244
|
+
*
|
|
245
|
+
* The request store's parent span passes the structure down. A loader or an
|
|
246
|
+
* action reads it once, when it is called. A middleware's span stays there
|
|
247
|
+
* for the rest of the chain, until the middleware finishes; an Effect
|
|
248
|
+
* middleware's own spans around `next()` take its place there.
|
|
249
|
+
*/
|
|
250
|
+
const traced =
|
|
251
|
+
<Args extends TracedArgs, Rest extends ReadonlyArray<unknown>>(
|
|
252
|
+
operation: Operation,
|
|
253
|
+
routeId: string,
|
|
254
|
+
storeKey: RouterContext<Context.Context<never>>,
|
|
255
|
+
fn: (args: Args, ...rest: Rest) => Promise<unknown>,
|
|
256
|
+
) =>
|
|
257
|
+
(args: Args, ...rest: Rest): Promise<unknown> => {
|
|
258
|
+
if (args.context.get(requestSpanKey) === undefined) return fn(args, ...rest);
|
|
259
|
+
const store = args.context.get(storeKey);
|
|
260
|
+
const program = Effect.useSpan(
|
|
261
|
+
`react-router.${operation} ${routeId}`,
|
|
262
|
+
{
|
|
263
|
+
kind: "internal",
|
|
264
|
+
attributes: {
|
|
265
|
+
"react_router.operation.name": operation,
|
|
266
|
+
"react_router.route.id": routeId,
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
(span) =>
|
|
270
|
+
Effect.promise(() => {
|
|
271
|
+
args.context.set(storeKey, Context.add(store, Tracer.ParentSpan, span));
|
|
272
|
+
const settled = settle(() => fn(args, ...rest));
|
|
273
|
+
if (operation !== "middleware") args.context.set(storeKey, store);
|
|
274
|
+
return settled;
|
|
275
|
+
}).pipe(
|
|
276
|
+
Effect.withParentSpan(span),
|
|
277
|
+
// `Effect.filterOrFail` passes its failure callback the whole `Settled`
|
|
278
|
+
// union, so `result.error` would need a branch that never runs.
|
|
279
|
+
// @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off
|
|
280
|
+
Effect.flatMap((result) =>
|
|
281
|
+
result.ok || !isSpanError(result.error)
|
|
282
|
+
? Effect.succeed(result)
|
|
283
|
+
: // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- a route function can throw any value, and React Router types it `unknown`
|
|
284
|
+
Effect.fail(result.error),
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
// oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- the error is that thrown value, rethrown below
|
|
289
|
+
return Effect.runPromiseExitWith(store)(program).then((exit) => {
|
|
290
|
+
if (operation === "middleware") args.context.set(storeKey, store);
|
|
291
|
+
if (Exit.isFailure(exit)) throw Cause.squash(exit.cause);
|
|
292
|
+
if (exit.value.ok) return exit.value.value;
|
|
293
|
+
throw exit.value.error;
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Build wrapping.
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
type ServerRoute = NonNullable<ServerBuild["routes"][string]>;
|
|
302
|
+
type ServerRouteModule = ServerRoute["module"];
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Wraps a route module's loader, action and middleware with the factory's
|
|
306
|
+
* runner, and traces them. The module itself is left as it is.
|
|
307
|
+
*/
|
|
308
|
+
const wrapModule = (
|
|
309
|
+
routeId: string,
|
|
310
|
+
module: ServerRouteModule,
|
|
311
|
+
handlers: RouteHandlers,
|
|
312
|
+
): ServerRouteModule => {
|
|
313
|
+
const store = handlers.requestContext;
|
|
314
|
+
return {
|
|
315
|
+
...module,
|
|
316
|
+
loader:
|
|
317
|
+
module.loader === undefined
|
|
318
|
+
? undefined
|
|
319
|
+
: traced("loader", routeId, store, handlers.loader(module.loader)),
|
|
320
|
+
action:
|
|
321
|
+
module.action === undefined
|
|
322
|
+
? undefined
|
|
323
|
+
: traced("action", routeId, store, handlers.action(module.action)),
|
|
324
|
+
middleware: module.middleware?.map(
|
|
325
|
+
// A wrapped middleware resolves with what the original returns: the
|
|
326
|
+
// route's response, data, or nothing.
|
|
327
|
+
(fn) =>
|
|
328
|
+
traced(
|
|
329
|
+
"middleware",
|
|
330
|
+
routeId,
|
|
331
|
+
store,
|
|
332
|
+
handlers.middleware(fn),
|
|
333
|
+
) as MiddlewareFunction<Response>,
|
|
334
|
+
),
|
|
335
|
+
};
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Wraps every route module of a build, and adds the adapter's
|
|
340
|
+
* instrumentations after the app's own in the server entry module. An app
|
|
341
|
+
* often does not export `instrumentations`, and `Struct.evolve` changes only
|
|
342
|
+
* the keys that an object has, so the key is set with `Struct.assign`.
|
|
343
|
+
*/
|
|
344
|
+
const wrapBuild = (
|
|
345
|
+
build: ServerBuild,
|
|
346
|
+
handlers: RouteHandlers,
|
|
347
|
+
instrumentations: ReadonlyArray<ServerInstrumentation>,
|
|
348
|
+
): ServerBuild =>
|
|
349
|
+
Struct.evolve(build, {
|
|
350
|
+
entry: (entry) =>
|
|
351
|
+
Struct.evolve(entry, {
|
|
352
|
+
module: (module) =>
|
|
353
|
+
Struct.assign(module, {
|
|
354
|
+
instrumentations: Arr.appendAll(module.instrumentations ?? [], instrumentations),
|
|
355
|
+
}),
|
|
356
|
+
}),
|
|
357
|
+
routes: (routes) =>
|
|
358
|
+
Record.map(routes, (route, id) =>
|
|
359
|
+
route === undefined
|
|
360
|
+
? undefined
|
|
361
|
+
: Struct.evolve(route, { module: (module) => wrapModule(id, module, handlers) }),
|
|
362
|
+
),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/** React Router's request handler, as `createRequestHandler` returns it. */
|
|
366
|
+
type RequestHandler = (request: Request, context: RouterContextProvider) => Promise<Response>;
|
|
367
|
+
|
|
368
|
+
/** A loaded build and React Router's request handler for it. */
|
|
369
|
+
interface LoadedBuild {
|
|
370
|
+
readonly build: ServerBuild;
|
|
371
|
+
readonly handler: RequestHandler;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Loads the build and returns React Router's request handler for it. The
|
|
376
|
+
* handler is built once per build value: React Router rebuilds its route
|
|
377
|
+
* tables on every request when it is given a function, so the adapter
|
|
378
|
+
* resolves the build itself. In development a new build after a change gets a
|
|
379
|
+
* new handler. The last handler is kept across requests, since a development
|
|
380
|
+
* build loads for each request.
|
|
381
|
+
*/
|
|
382
|
+
const makeBuildLoader = (
|
|
383
|
+
options: Pick<ReactRouter.Options, "build" | "factory">,
|
|
384
|
+
mode: string,
|
|
385
|
+
instrumentations: ReadonlyArray<ServerInstrumentation>,
|
|
386
|
+
): Effect.Effect<LoadedBuild> => {
|
|
387
|
+
let cached: LoadedBuild | undefined;
|
|
388
|
+
return Effect.promise(async () => {
|
|
389
|
+
const build = typeof options.build === "function" ? await options.build() : options.build;
|
|
390
|
+
if (cached?.build !== build) {
|
|
391
|
+
cached = {
|
|
392
|
+
build,
|
|
393
|
+
handler: createRequestHandler(
|
|
394
|
+
wrapBuild(build, options.factory.handlers, instrumentations),
|
|
395
|
+
mode,
|
|
396
|
+
),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return cached;
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// The adapter.
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Calls React Router. The call starts in the request fiber, so code that
|
|
409
|
+
* reads the async context, such as an OpenTelemetry tracer whose context the
|
|
410
|
+
* Effect tracer sets, sees the request's. When the request fiber is
|
|
411
|
+
* interrupted, because the client left, the request signal aborts, and a
|
|
412
|
+
* response that React Router resolves afterwards has its body cancelled.
|
|
413
|
+
*
|
|
414
|
+
* Effect's HTTP server runs the app uninterruptible, and interrupts the fiber
|
|
415
|
+
* only where the app opts in, so the call is interruptible.
|
|
416
|
+
*/
|
|
417
|
+
const callRouter = (
|
|
418
|
+
handler: RequestHandler,
|
|
419
|
+
request: Request,
|
|
420
|
+
context: RouterContextProvider,
|
|
421
|
+
controller: AbortController,
|
|
422
|
+
): Effect.Effect<Response> =>
|
|
423
|
+
Effect.callback<Response>((resume) => {
|
|
424
|
+
let interrupted = false;
|
|
425
|
+
handler(request, context).then(
|
|
426
|
+
(response) => {
|
|
427
|
+
if (interrupted) {
|
|
428
|
+
response.body?.cancel().catch(() => undefined);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
resume(Effect.succeed(response));
|
|
432
|
+
},
|
|
433
|
+
(error: unknown) => {
|
|
434
|
+
if (!interrupted) resume(Effect.die(error));
|
|
435
|
+
},
|
|
436
|
+
);
|
|
437
|
+
return Effect.sync(() => {
|
|
438
|
+
interrupted = true;
|
|
439
|
+
controller.abort();
|
|
440
|
+
});
|
|
441
|
+
}).pipe(Effect.interruptible);
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// The ReactRouter service.
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The methods of React Router's routes. Not the method `*`, which also
|
|
449
|
+
* registers HEAD: the router answers HEAD with the GET route of a path only
|
|
450
|
+
* when no HEAD route matches it, so a HEAD route on `*` takes HEAD from every
|
|
451
|
+
* GET route of the app.
|
|
452
|
+
*/
|
|
453
|
+
const routeMethods = Arr.filter(HttpMethod.all, (method) => method !== "HEAD");
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* React Router, as a service of the Effect HTTP app. `ReactRouter.layer`
|
|
457
|
+
* provides it, and `ReactRouter.routes` registers it on the app's router as
|
|
458
|
+
* the catch-all route.
|
|
459
|
+
*
|
|
460
|
+
* ```ts
|
|
461
|
+
* export const App = ReactRouter.routes.pipe(
|
|
462
|
+
* Layer.provide(
|
|
463
|
+
* ReactRouter.layer({
|
|
464
|
+
* build: () => import("virtual:react-router/server-build"),
|
|
465
|
+
* factory: routeFactory,
|
|
466
|
+
* mode: import.meta.env.MODE,
|
|
467
|
+
* }),
|
|
468
|
+
* ),
|
|
469
|
+
* );
|
|
470
|
+
* ```
|
|
471
|
+
*
|
|
472
|
+
* @stability unstable
|
|
473
|
+
*/
|
|
474
|
+
export class ReactRouter extends Context.Service<
|
|
475
|
+
ReactRouter,
|
|
476
|
+
{
|
|
477
|
+
/**
|
|
478
|
+
* The build, when the layer loaded it once at startup: a build value, or
|
|
479
|
+
* a build function in production mode. `None` when the build loads for
|
|
480
|
+
* each request.
|
|
481
|
+
*/
|
|
482
|
+
readonly build: Option.Option<ServerBuild>;
|
|
483
|
+
/**
|
|
484
|
+
* Answers the current request with React Router. The Scope is the
|
|
485
|
+
* request's scope: the resources that `loadContext` opens close with it.
|
|
486
|
+
*/
|
|
487
|
+
readonly handle: Effect.Effect<
|
|
488
|
+
HttpServerResponse.HttpServerResponse,
|
|
489
|
+
HttpServerError.RequestError,
|
|
490
|
+
HttpServerRequest.HttpServerRequest | Scope.Scope
|
|
491
|
+
>;
|
|
492
|
+
}
|
|
493
|
+
>()("react-router-effect/ReactRouter") {
|
|
494
|
+
/**
|
|
495
|
+
* Provides React Router. A build value, or a build function in production
|
|
496
|
+
* mode, loads when the layer builds. Provide one value of this layer to
|
|
497
|
+
* every layer that reads the service: two values load the build twice.
|
|
498
|
+
*
|
|
499
|
+
* @stability unstable
|
|
500
|
+
*/
|
|
501
|
+
static readonly layer = <R = never>(
|
|
502
|
+
options: ReactRouter.Options<R>,
|
|
503
|
+
): Layer.Layer<ReactRouter, never, Exclude<R, Scope.Scope>> =>
|
|
504
|
+
Layer.effect(ReactRouter, makeReactRouter(options));
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Registers React Router as the catch-all route of the app's router, on
|
|
508
|
+
* `*`, for every method of `HttpMethod.all` but HEAD. A route that the app
|
|
509
|
+
* registers itself wins over it, also a route on `/`, such as the
|
|
510
|
+
* prerendered home page of `ReactRouterAssets.layer`. A HEAD request uses
|
|
511
|
+
* the GET route of its path, React Router's or the app's own, as Express
|
|
512
|
+
* does. A method outside `HttpMethod.all` gets the router's 404. Register
|
|
513
|
+
* it at the root of the router: under a prefix, React Router does not get
|
|
514
|
+
* the path that it expects.
|
|
515
|
+
*
|
|
516
|
+
* @stability unstable
|
|
517
|
+
*/
|
|
518
|
+
static readonly routes = HttpRouter.use((router) =>
|
|
519
|
+
Effect.flatMap(ReactRouter, ({ handle }) =>
|
|
520
|
+
router.addAll(routeMethods.map((method) => HttpRouter.route(method, "*", handle))),
|
|
521
|
+
),
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export declare namespace ReactRouter {
|
|
526
|
+
/**
|
|
527
|
+
* The options of {@link ReactRouter.layer}. `R` is the requirement of `loadContext`.
|
|
528
|
+
*
|
|
529
|
+
* @stability unstable
|
|
530
|
+
*/
|
|
531
|
+
export interface Options<R = never> {
|
|
532
|
+
/** The server build: `() => import("virtual:react-router/server-build")`. */
|
|
533
|
+
readonly build: BuildSource;
|
|
534
|
+
/** The route factory whose runner, runtime and error model route Effects use. */
|
|
535
|
+
readonly factory: { readonly handlers: RouteHandlers };
|
|
536
|
+
/**
|
|
537
|
+
* React Router's server mode, such as `import.meta.env.MODE` in a Vite
|
|
538
|
+
* app. React Router runs in production mode for any mode but
|
|
539
|
+
* `development` and `test`, and so does the layer: it loads a build
|
|
540
|
+
* function for each request only in those two modes.
|
|
541
|
+
*/
|
|
542
|
+
readonly mode: "development" | "production" | "test" | (string & {});
|
|
543
|
+
/**
|
|
544
|
+
* Makes React Router's load context for each request, as `getLoadContext`
|
|
545
|
+
* does for `@react-router/express`. Plain loaders and actions, and
|
|
546
|
+
* `handleRequest` in `entry.server.tsx`, read its values with
|
|
547
|
+
* `context.get`. Route Effects read services from their Effect context,
|
|
548
|
+
* so an app gives them services with router middleware, not here. The
|
|
549
|
+
* adapter adds its own keys to the provider. The Effect gets the services
|
|
550
|
+
* of `R` from the layer, and the services of the request, which win over
|
|
551
|
+
* the services of the layer. A defect answers an empty 500.
|
|
552
|
+
*/
|
|
553
|
+
readonly loadContext?:
|
|
554
|
+
| ((
|
|
555
|
+
request: HttpServerRequest.HttpServerRequest,
|
|
556
|
+
) => Effect.Effect<RouterContextProvider, never, R>)
|
|
557
|
+
| undefined;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Makes the service. A build value, or a build function in production mode,
|
|
563
|
+
* loads once here, so a build that does not load stops the server's start.
|
|
564
|
+
* Any other build loads for each request, and gets a new React Router handler
|
|
565
|
+
* when its value changes.
|
|
566
|
+
*/
|
|
567
|
+
const makeReactRouter = Effect.fnUntraced(function* <R>(options: ReactRouter.Options<R>) {
|
|
568
|
+
const mode = options.mode;
|
|
569
|
+
// The tracer of the layer. With the native tracer, whose spans go
|
|
570
|
+
// nowhere, React Router gets no hook, and does no work for it on a request.
|
|
571
|
+
const tracer = yield* Effect.tracer;
|
|
572
|
+
const loadBuild = makeBuildLoader(
|
|
573
|
+
options,
|
|
574
|
+
mode,
|
|
575
|
+
tracer === Tracer.nativeTracer ? [] : [routeLabel],
|
|
576
|
+
);
|
|
577
|
+
const handlers = options.factory.handlers;
|
|
578
|
+
// The request's own services, such as its scope and its span, win over
|
|
579
|
+
// the services of the layer, as `HttpApiMiddleware` merges them.
|
|
580
|
+
const services = yield* Effect.context<R>();
|
|
581
|
+
const { loadContext } = options;
|
|
582
|
+
const provided: ReactRouter.Options["loadContext"] =
|
|
583
|
+
loadContext === undefined
|
|
584
|
+
? undefined
|
|
585
|
+
: (request) =>
|
|
586
|
+
Effect.updateContext(loadContext(request), (requestContext: Context.Context<never>) =>
|
|
587
|
+
Context.merge(services, requestContext),
|
|
588
|
+
);
|
|
589
|
+
// React Router runs in production mode for any mode but these two.
|
|
590
|
+
if (typeof options.build === "function" && (mode === "development" || mode === "test")) {
|
|
591
|
+
return ReactRouter.of({
|
|
592
|
+
build: Option.none(),
|
|
593
|
+
handle: handleRequest(handlers, loadBuild, provided),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
const loaded = yield* loadBuild;
|
|
597
|
+
return ReactRouter.of({
|
|
598
|
+
build: Option.some(loaded.build),
|
|
599
|
+
handle: handleRequest(handlers, Effect.succeed(loaded), provided),
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Answers the current request with React Router. Route Effects get the
|
|
605
|
+
* request fiber's context without its scope, so they see the services that
|
|
606
|
+
* the app's router middleware provides. A route that needs a scope opens its
|
|
607
|
+
* own with `Effect.scoped`.
|
|
608
|
+
*/
|
|
609
|
+
const handleRequest = (
|
|
610
|
+
handlers: RouteHandlers,
|
|
611
|
+
loadBuild: Effect.Effect<LoadedBuild>,
|
|
612
|
+
loadContext: ReactRouter.Options["loadContext"],
|
|
613
|
+
): ReactRouter["Service"]["handle"] =>
|
|
614
|
+
Effect.gen(function* () {
|
|
615
|
+
const httpRequest = yield* HttpServerRequest.HttpServerRequest;
|
|
616
|
+
const { handler } = yield* loadBuild;
|
|
617
|
+
const fiberContext = Context.omit(Scope.Scope)(yield* Effect.context<never>());
|
|
618
|
+
// `loadContext` runs first: a copy of the request below takes its body.
|
|
619
|
+
const routerContext =
|
|
620
|
+
loadContext === undefined ? new RouterContextProvider() : yield* loadContext(httpRequest);
|
|
621
|
+
// `Effect.abortSignal` needs a `Scope`, which `fiberContext` omits, and it aborts
|
|
622
|
+
// when that scope closes, even for a response that completed. The adapter aborts
|
|
623
|
+
// this controller itself, on interrupt and when the body stream ends early.
|
|
624
|
+
// @effect-diagnostics-next-line abortControllerInEffect:off
|
|
625
|
+
const controller = new AbortController();
|
|
626
|
+
const converted = yield* HttpServerRequest.toWeb(httpRequest, { signal: controller.signal });
|
|
627
|
+
// On a Web server, such as a Worker, `toWeb` returns the runtime's own
|
|
628
|
+
// Request and ignores `signal`. A copy follows both signals. A body that
|
|
629
|
+
// is used or locked cannot be copied, so that request goes to React
|
|
630
|
+
// Router as it is.
|
|
631
|
+
const request =
|
|
632
|
+
converted === httpRequest.source && !converted.bodyUsed && converted.body?.locked !== true
|
|
633
|
+
? new Request(converted, { signal: AbortSignal.any([converted.signal, controller.signal]) })
|
|
634
|
+
: converted;
|
|
635
|
+
routerContext.set(handlers.requestContext, fiberContext);
|
|
636
|
+
routerContext.set(requestSpanKey, serverSpan(fiberContext));
|
|
637
|
+
const response = yield* callRouter(handler, request, routerContext, controller);
|
|
638
|
+
const setCookies = response.headers.getSetCookie();
|
|
639
|
+
const cookies = Cookies.fromSetCookie(setCookies);
|
|
640
|
+
const dropped = droppedCookies(setCookies, cookies);
|
|
641
|
+
if (dropped.length > 0) {
|
|
642
|
+
yield* Effect.logError(
|
|
643
|
+
"React Router adapter dropped cookies that Effect cannot send",
|
|
644
|
+
dropped,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
return toHttpResponse(response, cookies, controller);
|
|
648
|
+
}).pipe(
|
|
649
|
+
// React Router logs and answers the errors of route code. A defect of the
|
|
650
|
+
// adapter's own steps, such as a `loadContext` that dies, a build that
|
|
651
|
+
// does not load, or React Router's handler rejecting, goes to the app's
|
|
652
|
+
// loggers, and the client gets an empty 500, as Express's error handler
|
|
653
|
+
// answers.
|
|
654
|
+
Effect.catchDefect((defect) =>
|
|
655
|
+
Effect.logError("React Router adapter failed", defect).pipe(
|
|
656
|
+
Effect.as(HttpServerResponse.empty({ status: 500 })),
|
|
657
|
+
),
|
|
658
|
+
),
|
|
659
|
+
);
|