react-router-effect 1.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,647 @@
1
+ /**
2
+ * Serves a React Router framework-mode build from Effect's Node 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
+ * @since 2.1.0
24
+ */
25
+ import {
26
+ Cause,
27
+ Context,
28
+ Data,
29
+ Effect,
30
+ Exit,
31
+ FileSystem,
32
+ Option,
33
+ Path,
34
+ Scope,
35
+ Stream,
36
+ Tracer,
37
+ } from "effect";
38
+ import {
39
+ Cookies,
40
+ type HttpPlatform,
41
+ HttpServerError,
42
+ HttpServerRequest,
43
+ HttpServerResponse,
44
+ HttpStaticServer,
45
+ } from "effect/http";
46
+ import {
47
+ createContext,
48
+ createRequestHandler,
49
+ RouterContextProvider,
50
+ type RouterContext,
51
+ type ServerBuild,
52
+ type MiddlewareFunction,
53
+ } from "react-router";
54
+
55
+ import type { RouteHandlers } from "./factory.ts";
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Options.
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /** A React Router server build, or a function that loads it, as in `createRequestHandler`. */
62
+ export type BuildSource = ServerBuild | (() => ServerBuild | Promise<ServerBuild>);
63
+
64
+ /** The options of {@link make}. */
65
+ export interface Options {
66
+ /** The server build: `() => import("virtual:react-router/server-build")`. */
67
+ readonly build: BuildSource;
68
+ /** The route factory whose runner, runtime and error model route Effects use. */
69
+ readonly factory: { readonly handlers: RouteHandlers };
70
+ /** React Router's server mode. Defaults to `process.env.NODE_ENV`. */
71
+ readonly mode?: string | undefined;
72
+ /**
73
+ * Serves the client build before React Router: the files of the build's
74
+ * `assetsBuildDirectory`, under its `publicPath`. Files under `assets/` have
75
+ * hashed names and are cached for a year as immutable, other files for an
76
+ * hour. It serves like `express.static`: a directory serves its index file,
77
+ * such as a prerendered page, its path without the trailing slash redirects
78
+ * to the path with it, dotfiles are not served, and a request it does not
79
+ * serve goes to React Router. Like `express.static`, it looks each GET and
80
+ * HEAD request up in the file system. It serves only a build that it loads
81
+ * once: in production mode, or a build value. A
82
+ * development build is never served, since Vite serves the client there, nor
83
+ * is a `publicPath` on another origin. Making the app fails with a
84
+ * {@link ClientBuildError} when it cannot read the directory. Needs the
85
+ * platform's file system, which `NodeHttpServer.layer` and the other server
86
+ * layers provide.
87
+ */
88
+ readonly clientAssets?: boolean | undefined;
89
+ /**
90
+ * Builds the services of one request, before React Router runs. They join
91
+ * the request's effect context, so every middleware, loader and action sees
92
+ * them without a middleware that sets them.
93
+ */
94
+ readonly requestContext?: ((request: Request) => Context.Context<never>) | undefined;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Response conversion.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Converts React Router's response. Its `Set-Cookie` values become the
103
+ * response's cookies, as `HttpServerResponse.fromWeb` converts them, so
104
+ * middleware after the adapter can read and change them. Effect keeps one
105
+ * cookie per name, the last one, and only the attributes it knows.
106
+ *
107
+ * Effect gives a streamed body without a content type
108
+ * `application/octet-stream`, so a route that sends a body sets its own.
109
+ *
110
+ * When the body stream ends before it finished, because the client left or a
111
+ * write failed, the request signal aborts: React Router then stops streamed
112
+ * promises, and route code sees the abort. A completed response never aborts it.
113
+ */
114
+ const toHttpResponse = (
115
+ response: Response,
116
+ controller: AbortController,
117
+ ): HttpServerResponse.HttpServerResponse => {
118
+ const headers = new Headers(response.headers);
119
+ headers.delete("set-cookie");
120
+ // React Router leaves `statusText` empty; Node then sends its standard reason phrase.
121
+ const init = {
122
+ status: response.status,
123
+ statusText: response.statusText === "" ? undefined : response.statusText,
124
+ headers,
125
+ cookies: Cookies.fromSetCookie(response.headers.getSetCookie()),
126
+ };
127
+ const body = response.body;
128
+ return body === null
129
+ ? HttpServerResponse.empty(init)
130
+ : HttpServerResponse.stream(
131
+ Stream.fromReadableStream({ evaluate: () => body, onError: (error) => error }).pipe(
132
+ Stream.onExit((exit) =>
133
+ Exit.isSuccess(exit) ? Effect.void : Effect.sync(() => controller.abort()),
134
+ ),
135
+ ),
136
+ init,
137
+ );
138
+ };
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Client assets.
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** The services that serving the client build needs. */
145
+ export type ClientAssetServices = FileSystem.FileSystem | Path.Path | HttpPlatform.HttpPlatform;
146
+
147
+ /** The `Cache-Control` of the files under `assets/`, whose names include a content hash. */
148
+ const hashedCacheControl = "public, max-age=31536000, immutable";
149
+
150
+ /** The `Cache-Control` of the other client files, such as `favicon.ico`. */
151
+ const otherCacheControl = "public, max-age=3600";
152
+
153
+ /** Serves files of one directory, for the current request. */
154
+ type StaticFiles = Effect.Effect<
155
+ HttpServerResponse.HttpServerResponse,
156
+ HttpServerError.HttpServerError,
157
+ HttpServerRequest.HttpServerRequest
158
+ >;
159
+
160
+ /** The client build of one server build. */
161
+ interface ClientFiles {
162
+ /** The `publicPath` without its trailing slash. */
163
+ readonly prefix: string;
164
+ readonly hashed: StaticFiles;
165
+ readonly other: StaticFiles;
166
+ }
167
+
168
+ /**
169
+ * True for a path with a segment that starts with a dot, such as `.vite/`,
170
+ * which is never served. Effect's static server serves dotfiles, and Vite
171
+ * writes its manifest there. `express.static` ignores them.
172
+ */
173
+ const hasDotSegment = (path: string): boolean =>
174
+ path.split("/").some((segment) => segment.startsWith("."));
175
+
176
+ /** True when a static lookup found no file, so the request goes on to React Router. */
177
+ const isNotFound = (error: HttpServerError.HttpServerError): boolean =>
178
+ error.reason._tag === "RouteNotFound";
179
+
180
+ /**
181
+ * The app serves the client build, and cannot read its directory. The
182
+ * build's `assetsBuildDirectory` is relative to the working directory of the
183
+ * server, so `directory` is the path it resolved to.
184
+ */
185
+ export class ClientBuildError extends Data.TaggedError("ClientBuildError")<{
186
+ readonly message: string;
187
+ /** The resolved directory of the client build. */
188
+ readonly directory: string;
189
+ readonly cause: unknown;
190
+ }> {}
191
+
192
+ /**
193
+ * The client files of a build, or `undefined` when the adapter does not serve
194
+ * them. Making them reads the build's directory, so a missing client build
195
+ * fails the server's start and not its requests.
196
+ */
197
+ const makeClientFiles = (
198
+ build: ServerBuild,
199
+ ): Effect.Effect<ClientFiles | undefined, ClientBuildError, ClientAssetServices> =>
200
+ Effect.gen(function* () {
201
+ if (build.assets.hmr !== undefined || !build.publicPath.startsWith("/")) return undefined;
202
+ const fileSystem = yield* FileSystem.FileSystem;
203
+ const path = yield* Path.Path;
204
+ const root = path.resolve(build.assetsBuildDirectory);
205
+ yield* fileSystem.readDirectory(root).pipe(
206
+ Effect.mapError(
207
+ (cause) =>
208
+ new ClientBuildError({
209
+ message: `Cannot read the client build at ${root}`,
210
+ directory: root,
211
+ cause,
212
+ }),
213
+ ),
214
+ );
215
+ const files = (cacheControl: string) =>
216
+ HttpStaticServer.make({ root, cacheControl }).pipe(
217
+ // Making a static server only resolves its root.
218
+ Effect.orDie,
219
+ );
220
+ return {
221
+ prefix: build.publicPath.replace(/\/$/, ""),
222
+ hashed: yield* files(hashedCacheControl),
223
+ other: yield* files(otherCacheControl),
224
+ };
225
+ });
226
+
227
+ /** The decoded path of a URL, or `undefined` when it does not decode. */
228
+ const decodePath = (path: string): string | undefined => {
229
+ try {
230
+ return decodeURIComponent(path);
231
+ } catch {
232
+ return undefined;
233
+ }
234
+ };
235
+
236
+ /**
237
+ * Serves the request from the client build, or returns `None` when the build
238
+ * has no such file: a path the build does not have, a directory without an
239
+ * index file, a dotfile, a path outside the `publicPath`, or a method other
240
+ * than GET and HEAD.
241
+ */
242
+ const serveClientFile = (
243
+ files: ClientFiles,
244
+ request: HttpServerRequest.HttpServerRequest,
245
+ ): Effect.Effect<
246
+ Option.Option<HttpServerResponse.HttpServerResponse>,
247
+ HttpServerError.HttpServerError
248
+ > => {
249
+ const path = request.url.split("?")[0] ?? "";
250
+ if (
251
+ (request.method !== "GET" && request.method !== "HEAD") ||
252
+ !path.startsWith(`${files.prefix}/`)
253
+ ) {
254
+ return Effect.succeedNone;
255
+ }
256
+ const url = request.url.slice(files.prefix.length);
257
+ const file = decodePath(url.split("?")[0] ?? "");
258
+ // The decoded path, so an encoded dot such as `%2e` is a dotfile too.
259
+ if (file === undefined || hasDotSegment(file)) return Effect.succeedNone;
260
+ const serve = url.startsWith("/assets/") ? files.hashed : files.other;
261
+ return serve.pipe(
262
+ Effect.provideService(HttpServerRequest.HttpServerRequest, request.modify({ url })),
263
+ Effect.map(Option.some),
264
+ Effect.catchIf(isNotFound, () => Effect.succeedNone),
265
+ );
266
+ };
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Tracing.
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * The span of a traced request: the server span of Effect's HTTP tracer, with
274
+ * a tracer that exports spans. Without it, the adapter adds no spans.
275
+ */
276
+ const requestSpanKey: RouterContext<Tracer.Span | undefined> = createContext<
277
+ Tracer.Span | undefined
278
+ >(undefined);
279
+
280
+ /**
281
+ * The span of the request fiber, when a tracer exports it: not the native
282
+ * tracer, whose spans go nowhere, and not an unsampled span.
283
+ */
284
+ const exportedSpan = (context: Context.Context<never>): Tracer.Span | undefined => {
285
+ const span = Context.getOrUndefined(context, Tracer.ParentSpan);
286
+ return span?._tag === "Span" &&
287
+ span.sampled &&
288
+ Context.get(context, Tracer.Tracer) !== Tracer.nativeTracer
289
+ ? span
290
+ : undefined;
291
+ };
292
+
293
+ /** What a route function produced: its value, or what it threw. */
294
+ type Settled =
295
+ | { readonly ok: true; readonly value: unknown }
296
+ | { readonly ok: false; readonly error: unknown };
297
+
298
+ /** Calls a route function and captures its result, a synchronous throw included. */
299
+ const settle = (call: () => unknown): Promise<Settled> => {
300
+ try {
301
+ return Promise.resolve(call()).then(
302
+ (value): Settled => ({ ok: true, value }),
303
+ (error: unknown): Settled => ({ ok: false, error }),
304
+ );
305
+ } catch (error) {
306
+ return Promise.resolve({ ok: false, error });
307
+ }
308
+ };
309
+
310
+ /** The status of a thrown `Response` or `data(...)`, which React Router sends as a response. */
311
+ const thrownStatus = (error: unknown): number | undefined => {
312
+ if (error instanceof Response) return error.status;
313
+ if (typeof error !== "object" || error === null || !("type" in error)) return undefined;
314
+ if (error.type !== "DataWithResponseInit" || !("init" in error)) return undefined;
315
+ const init = error.init;
316
+ return typeof init === "object" &&
317
+ init !== null &&
318
+ "status" in init &&
319
+ typeof init.status === "number"
320
+ ? init.status
321
+ : 200;
322
+ };
323
+
324
+ /**
325
+ * A thrown value is an error of the span unless React Router sends it as a
326
+ * response below 500, such as a redirect or `data(..., 404)`.
327
+ */
328
+ const isSpanError = (error: unknown): boolean => {
329
+ const status = thrownStatus(error);
330
+ return status === undefined || status >= 500;
331
+ };
332
+
333
+ /** What every route function gets, as far as tracing reads it. */
334
+ interface TracedArgs {
335
+ readonly context: Readonly<RouterContextProvider>;
336
+ /** The routed pattern of the request, such as `/posts/:id`. */
337
+ readonly pattern?: string | undefined;
338
+ }
339
+
340
+ /** The kind of route function, as React Router names it. */
341
+ type Operation = "middleware" | "loader" | "action";
342
+
343
+ /**
344
+ * Runs a route function in a span of its own when the request is traced,
345
+ * such as `react-router.loader routes/home`, and sets `http.route` on the
346
+ * request's span.
347
+ *
348
+ * The spans follow React Router's structure. A middleware's span is the
349
+ * parent of everything its `next()` runs: the next middleware, then the
350
+ * loaders and the action. A route Effect nests under its function's span, and
351
+ * so do the clients an OpenTelemetry instrumentation traces, since Effect's
352
+ * OpenTelemetry tracer makes the span active while the function is called.
353
+ *
354
+ * The request store's parent span passes the structure down. A loader or an
355
+ * action reads it once, when it is called. A middleware's span stays there
356
+ * for the rest of the chain, until the middleware finishes; an Effect
357
+ * middleware's own spans around `next()` take its place there.
358
+ */
359
+ const traced =
360
+ <Args extends TracedArgs, Rest extends ReadonlyArray<unknown>>(
361
+ operation: Operation,
362
+ routeId: string,
363
+ storeKey: RouterContext<Context.Context<never>>,
364
+ fn: (args: Args, ...rest: Rest) => Promise<unknown>,
365
+ ) =>
366
+ (args: Args, ...rest: Rest): Promise<unknown> => {
367
+ const requestSpan = args.context.get(requestSpanKey);
368
+ if (requestSpan === undefined) return fn(args, ...rest);
369
+ if (typeof args.pattern === "string") {
370
+ // React Router joins the route paths, so a root route without a path leaves out the leading slash.
371
+ const route = args.pattern.startsWith("/") ? args.pattern : `/${args.pattern}`;
372
+ requestSpan.attribute("http.route", route);
373
+ }
374
+ const store = args.context.get(storeKey);
375
+ const program = Effect.useSpan(
376
+ `react-router.${operation} ${routeId}`,
377
+ {
378
+ kind: "internal",
379
+ attributes: {
380
+ "react_router.operation.name": operation,
381
+ "react_router.route.id": routeId,
382
+ },
383
+ },
384
+ (span) =>
385
+ Effect.sync(() => {
386
+ args.context.set(storeKey, Context.add(store, Tracer.ParentSpan, span));
387
+ const settled = settle(() => fn(args, ...rest));
388
+ if (operation !== "middleware") args.context.set(storeKey, store);
389
+ return settled;
390
+ }).pipe(
391
+ Effect.withParentSpan(span),
392
+ Effect.flatMap((settled) => Effect.promise(() => settled)),
393
+ Effect.flatMap((result) =>
394
+ result.ok || !isSpanError(result.error)
395
+ ? Effect.succeed(result)
396
+ : Effect.fail(result.error),
397
+ ),
398
+ ),
399
+ );
400
+ return Effect.runPromiseExitWith(store)(program).then((exit) => {
401
+ if (operation === "middleware") args.context.set(storeKey, store);
402
+ if (Exit.isFailure(exit)) throw Cause.squash(exit.cause);
403
+ if (exit.value.ok) return exit.value.value;
404
+ throw exit.value.error;
405
+ });
406
+ };
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // Build wrapping.
410
+ // ---------------------------------------------------------------------------
411
+
412
+ type ServerRoute = NonNullable<ServerBuild["routes"][string]>;
413
+ type ServerRouteModule = ServerRoute["module"];
414
+
415
+ /**
416
+ * Wraps a route module's loader, action and middleware with the factory's
417
+ * runner, and traces them. The module itself is left as it is.
418
+ */
419
+ const wrapModule = (
420
+ routeId: string,
421
+ module: ServerRouteModule,
422
+ handlers: RouteHandlers,
423
+ ): ServerRouteModule => {
424
+ const store = handlers.requestContext;
425
+ return {
426
+ ...module,
427
+ loader:
428
+ module.loader === undefined
429
+ ? undefined
430
+ : traced("loader", routeId, store, handlers.loader(module.loader)),
431
+ action:
432
+ module.action === undefined
433
+ ? undefined
434
+ : traced("action", routeId, store, handlers.action(module.action)),
435
+ middleware: module.middleware?.map(
436
+ // A wrapped middleware resolves with what the original returns: the
437
+ // route's response, data, or nothing.
438
+ (fn) =>
439
+ traced(
440
+ "middleware",
441
+ routeId,
442
+ store,
443
+ handlers.middleware(fn),
444
+ ) as MiddlewareFunction<Response>,
445
+ ),
446
+ };
447
+ };
448
+
449
+ /** Wraps every route module of a build. */
450
+ const wrapBuild = (build: ServerBuild, handlers: RouteHandlers): ServerBuild => ({
451
+ ...build,
452
+ routes: Object.fromEntries(
453
+ Object.entries(build.routes).map(([id, route]) => [
454
+ id,
455
+ route === undefined
456
+ ? undefined
457
+ : { ...route, module: wrapModule(id, route.module, handlers) },
458
+ ]),
459
+ ),
460
+ });
461
+
462
+ /** React Router's request handler, as `createRequestHandler` returns it. */
463
+ type RequestHandler = (request: Request, context: RouterContextProvider) => Promise<Response>;
464
+
465
+ /** A loaded build and React Router's request handler for it. */
466
+ interface LoadedBuild {
467
+ readonly build: ServerBuild;
468
+ readonly handler: RequestHandler;
469
+ }
470
+
471
+ /**
472
+ * Loads the build and returns React Router's request handler for it. The
473
+ * handler is built once per build value: React Router rebuilds its route
474
+ * tables on every request when it is given a function, so the adapter
475
+ * resolves the build itself. In development a new build after a change gets a
476
+ * new handler. The last handler is kept across runs of {@link make}'s Effect,
477
+ * since a development server can make the app for each request.
478
+ */
479
+ const makeBuildLoader = (
480
+ options: Options,
481
+ mode: string | undefined,
482
+ ): Effect.Effect<LoadedBuild> => {
483
+ let cached: LoadedBuild | undefined;
484
+ return Effect.promise(async () => {
485
+ const build = typeof options.build === "function" ? await options.build() : options.build;
486
+ if (cached?.build !== build) {
487
+ cached = {
488
+ build,
489
+ handler: createRequestHandler(wrapBuild(build, options.factory.handlers), mode),
490
+ };
491
+ }
492
+ return cached;
493
+ });
494
+ };
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // The adapter.
498
+ // ---------------------------------------------------------------------------
499
+
500
+ /**
501
+ * Calls React Router. The call starts in the request fiber, so code that
502
+ * reads the async context, such as an OpenTelemetry tracer whose context the
503
+ * Effect tracer sets, sees the request's. When the request fiber is
504
+ * interrupted, because the client left, the request signal aborts, and a
505
+ * response that React Router resolves afterwards has its body cancelled.
506
+ *
507
+ * Effect's HTTP server runs the app uninterruptible, and interrupts the fiber
508
+ * only where the app opts in, so the call is interruptible.
509
+ */
510
+ const callRouter = (
511
+ handler: RequestHandler,
512
+ request: Request,
513
+ context: RouterContextProvider,
514
+ controller: AbortController,
515
+ ): Effect.Effect<Response> =>
516
+ Effect.callback<Response>((resume) => {
517
+ let interrupted = false;
518
+ handler(request, context).then(
519
+ (response) => {
520
+ if (interrupted) {
521
+ response.body?.cancel().catch(() => undefined);
522
+ return;
523
+ }
524
+ resume(Effect.succeed(response));
525
+ },
526
+ (error: unknown) => {
527
+ if (!interrupted) resume(Effect.die(error));
528
+ },
529
+ );
530
+ return Effect.sync(() => {
531
+ interrupted = true;
532
+ controller.abort();
533
+ });
534
+ }).pipe(Effect.interruptible);
535
+
536
+ /** True when the options serve the client build. */
537
+ type ServesClientAssets<O> = O extends { readonly clientAssets: infer Serves }
538
+ ? [Serves] extends [false | undefined]
539
+ ? false
540
+ : true
541
+ : false;
542
+
543
+ /**
544
+ * The Effect HTTP app {@link make} makes. Serving the client build adds the
545
+ * static file server's errors to its errors.
546
+ */
547
+ export type App<O extends Options> = Effect.Effect<
548
+ HttpServerResponse.HttpServerResponse,
549
+ | HttpServerError.RequestError
550
+ | (ServesClientAssets<O> extends true ? HttpServerError.HttpServerError : never),
551
+ HttpServerRequest.HttpServerRequest
552
+ >;
553
+
554
+ /**
555
+ * Makes the Effect HTTP app that serves React Router. Run the Effect once,
556
+ * when the server starts. In production it loads the build, and with
557
+ * `clientAssets` it reads the client build's directory, so a missing one fails
558
+ * the server's start with a {@link ClientBuildError} and not its requests.
559
+ *
560
+ * ```ts
561
+ * import { ReactRouterHttp } from "react-router-effect/server";
562
+ *
563
+ * export const reactRouterApp = ReactRouterHttp.make({
564
+ * build: () => import("virtual:react-router/server-build"),
565
+ * factory: routeFactory,
566
+ * clientAssets: true,
567
+ * });
568
+ *
569
+ * // Serve it like any Effect HTTP app, with the app's own middleware.
570
+ * Layer.unwrap(
571
+ * Effect.map(reactRouterApp, (app) => HttpServer.serve(app, HttpMiddleware.logger)),
572
+ * );
573
+ * ```
574
+ *
575
+ * In development, where the build changes, the app loads the build for each
576
+ * request, and making it does nothing else.
577
+ *
578
+ * The request signal aborts only when the response ends early: when the
579
+ * client leaves while React Router runs or while the body streams. A
580
+ * completed response never aborts it.
581
+ */
582
+ export const make = <const O extends Options>(
583
+ options: O,
584
+ ): Effect.Effect<
585
+ App<O>,
586
+ ServesClientAssets<O> extends true ? ClientBuildError : never,
587
+ ServesClientAssets<O> extends true ? ClientAssetServices : never
588
+ > => {
589
+ const mode = options.mode ?? process.env.NODE_ENV;
590
+ const loadBuild = makeBuildLoader(options, mode);
591
+ const settled = typeof options.build !== "function" || mode === "production";
592
+ const made = Effect.gen(function* () {
593
+ if (!settled) return serve(options, loadBuild, undefined);
594
+ const loaded = yield* loadBuild;
595
+ const files = options.clientAssets === true ? yield* makeClientFiles(loaded.build) : undefined;
596
+ return serve(options, Effect.succeed(loaded), files);
597
+ });
598
+ // The client build's errors and requirements apply only when the options serve it.
599
+ return made as unknown as Effect.Effect<
600
+ App<O>,
601
+ ServesClientAssets<O> extends true ? ClientBuildError : never,
602
+ ServesClientAssets<O> extends true ? ClientAssetServices : never
603
+ >;
604
+ };
605
+
606
+ /** The app for one loaded build source, and the client files it serves, if any. */
607
+ const serve = (
608
+ options: Options,
609
+ loadBuild: Effect.Effect<LoadedBuild>,
610
+ files: ClientFiles | undefined,
611
+ ): Effect.Effect<
612
+ HttpServerResponse.HttpServerResponse,
613
+ HttpServerError.RequestError | HttpServerError.HttpServerError,
614
+ HttpServerRequest.HttpServerRequest
615
+ > =>
616
+ Effect.gen(function* () {
617
+ const httpRequest = yield* HttpServerRequest.HttpServerRequest;
618
+ if (files !== undefined) {
619
+ const file = yield* serveClientFile(files, httpRequest);
620
+ if (Option.isSome(file)) return file.value;
621
+ }
622
+ const { handler } = yield* loadBuild;
623
+ // Route effects get the request fiber's context, without its scope: a
624
+ // route that needs a scope opens its own with `Effect.scoped`.
625
+ const fiberContext = Context.omit(Scope.Scope)(yield* Effect.context<never>());
626
+ const controller = new AbortController();
627
+ const request = yield* HttpServerRequest.toWeb(httpRequest, { signal: controller.signal });
628
+ const routerContext = new RouterContextProvider();
629
+ routerContext.set(
630
+ options.factory.handlers.requestContext,
631
+ Context.merge(fiberContext, options.requestContext?.(request) ?? Context.empty()),
632
+ );
633
+ routerContext.set(requestSpanKey, exportedSpan(fiberContext));
634
+ const response = yield* callRouter(handler, request, routerContext, controller);
635
+ return toHttpResponse(response, controller);
636
+ }).pipe(
637
+ // React Router logs and answers the errors of route code. A defect of the
638
+ // adapter's own steps, such as a `requestContext` that throws, a build
639
+ // that does not load, or React Router's handler rejecting, goes to the
640
+ // app's loggers, and the client gets a 500, as Express's error handler
641
+ // does. Answering it keeps a logger middleware from logging it again.
642
+ Effect.catchDefect((defect) =>
643
+ Effect.logError("React Router adapter failed", defect).pipe(
644
+ Effect.as(HttpServerResponse.empty({ status: 500 })),
645
+ ),
646
+ ),
647
+ );
@@ -0,0 +1,2 @@
1
+ /** `react-router-effect/vite`: React Router's Vite dev server in front of the Effect HTTP app, on Node. */
2
+ export * as ReactRouterVite from "./vite.ts";