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/src/server.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Serves a React Router framework-mode build from Effect's Node HTTP server.
2
+ * Serves a React Router framework-mode build from Effect's HTTP server.
3
3
  *
4
4
  * The adapter converts each `HttpServerRequest` into a Web `Request`, calls
5
5
  * React Router's own request handler, and streams the `Response` back. React
@@ -20,28 +20,33 @@
20
20
  * through unchanged, so existing promise loaders and `makeLoader` wrappers keep
21
21
  * working.
22
22
  *
23
+ * The adapter's API is unstable: a minor version can change it.
24
+ *
25
+ * @stability unstable
23
26
  * @since 2.1.0
24
27
  */
25
28
  import {
29
+ Array as Arr,
26
30
  Cause,
27
31
  Context,
28
- Data,
29
32
  Effect,
30
33
  Exit,
31
- FileSystem,
34
+ Layer,
32
35
  Option,
33
- Path,
36
+ Predicate,
37
+ Record,
34
38
  Scope,
35
39
  Stream,
40
+ Struct,
36
41
  Tracer,
37
42
  } from "effect";
38
43
  import {
39
44
  Cookies,
40
- type HttpPlatform,
45
+ HttpMethod,
46
+ HttpRouter,
41
47
  HttpServerError,
42
48
  HttpServerRequest,
43
49
  HttpServerResponse,
44
- HttpStaticServer,
45
50
  } from "effect/http";
46
51
  import {
47
52
  createContext,
@@ -49,50 +54,14 @@ import {
49
54
  RouterContextProvider,
50
55
  type RouterContext,
51
56
  type ServerBuild,
57
+ type ServerInstrumentation,
52
58
  type MiddlewareFunction,
53
59
  } from "react-router";
54
60
 
55
61
  import type { RouteHandlers } from "./factory.ts";
56
62
 
57
- // ---------------------------------------------------------------------------
58
- // Options.
59
- // ---------------------------------------------------------------------------
60
-
61
63
  /** 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
- }
64
+ type BuildSource = ServerBuild | (() => ServerBuild | Promise<ServerBuild>);
96
65
 
97
66
  // ---------------------------------------------------------------------------
98
67
  // Response conversion.
@@ -102,7 +71,8 @@ export interface Options {
102
71
  * Converts React Router's response. Its `Set-Cookie` values become the
103
72
  * response's cookies, as `HttpServerResponse.fromWeb` converts them, so
104
73
  * 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.
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.
106
76
  *
107
77
  * Effect gives a streamed body without a content type
108
78
  * `application/octet-stream`, so a route that sends a body sets its own.
@@ -113,6 +83,7 @@ export interface Options {
113
83
  */
114
84
  const toHttpResponse = (
115
85
  response: Response,
86
+ cookies: Cookies.Cookies,
116
87
  controller: AbortController,
117
88
  ): HttpServerResponse.HttpServerResponse => {
118
89
  const headers = new Headers(response.headers);
@@ -122,7 +93,7 @@ const toHttpResponse = (
122
93
  status: response.status,
123
94
  statusText: response.statusText === "" ? undefined : response.statusText,
124
95
  headers,
125
- cookies: Cookies.fromSetCookie(response.headers.getSetCookie()),
96
+ cookies,
126
97
  };
127
98
  const body = response.body;
128
99
  return body === null
@@ -137,157 +108,80 @@ const toHttpResponse = (
137
108
  );
138
109
  };
139
110
 
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
111
  /**
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.
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.
172
116
  */
173
- const hasDotSegment = (path: string): boolean =>
174
- path.split("/").some((segment) => segment.startsWith("."));
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() ?? "");
175
127
 
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
- }> {}
128
+ // ---------------------------------------------------------------------------
129
+ // Tracing.
130
+ // ---------------------------------------------------------------------------
191
131
 
192
132
  /**
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.
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.
196
136
  */
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
- };
137
+ const requestSpanKey: RouterContext<Tracer.Span | undefined> = createContext<
138
+ Tracer.Span | undefined
139
+ >(undefined);
235
140
 
236
141
  /**
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.
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.
241
149
  */
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;
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);
255
155
  }
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
- );
156
+ return Predicate.isTagged(span, "Span") && span.sampled ? span : undefined;
266
157
  };
267
158
 
268
- // ---------------------------------------------------------------------------
269
- // Tracing.
270
- // ---------------------------------------------------------------------------
271
-
272
159
  /**
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.
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.
275
163
  */
276
- const requestSpanKey: RouterContext<Tracer.Span | undefined> = createContext<
277
- Tracer.Span | undefined
278
- >(undefined);
164
+ const httpRoute = (pattern: string): string => (pattern.startsWith("/") ? pattern : `/${pattern}`);
279
165
 
280
166
  /**
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.
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 `*`.
283
172
  */
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;
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
+ },
291
185
  };
292
186
 
293
187
  /** What a route function produced: its value, or what it threw. */
@@ -333,8 +227,6 @@ const isSpanError = (error: unknown): boolean => {
333
227
  /** What every route function gets, as far as tracing reads it. */
334
228
  interface TracedArgs {
335
229
  readonly context: Readonly<RouterContextProvider>;
336
- /** The routed pattern of the request, such as `/posts/:id`. */
337
- readonly pattern?: string | undefined;
338
230
  }
339
231
 
340
232
  /** The kind of route function, as React Router names it. */
@@ -342,8 +234,7 @@ type Operation = "middleware" | "loader" | "action";
342
234
 
343
235
  /**
344
236
  * 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.
237
+ * such as `react-router.loader routes/home`. `routeLabel` sets `http.route`.
347
238
  *
348
239
  * The spans follow React Router's structure. A middleware's span is the
349
240
  * parent of everything its `next()` runs: the next middleware, then the
@@ -364,13 +255,7 @@ const traced =
364
255
  fn: (args: Args, ...rest: Rest) => Promise<unknown>,
365
256
  ) =>
366
257
  (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
- }
258
+ if (args.context.get(requestSpanKey) === undefined) return fn(args, ...rest);
374
259
  const store = args.context.get(storeKey);
375
260
  const program = Effect.useSpan(
376
261
  `react-router.${operation} ${routeId}`,
@@ -382,21 +267,25 @@ const traced =
382
267
  },
383
268
  },
384
269
  (span) =>
385
- Effect.sync(() => {
270
+ Effect.promise(() => {
386
271
  args.context.set(storeKey, Context.add(store, Tracer.ParentSpan, span));
387
272
  const settled = settle(() => fn(args, ...rest));
388
273
  if (operation !== "middleware") args.context.set(storeKey, store);
389
274
  return settled;
390
275
  }).pipe(
391
276
  Effect.withParentSpan(span),
392
- Effect.flatMap((settled) => Effect.promise(() => settled)),
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
393
280
  Effect.flatMap((result) =>
394
281
  result.ok || !isSpanError(result.error)
395
282
  ? Effect.succeed(result)
396
- : Effect.fail(result.error),
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),
397
285
  ),
398
286
  ),
399
287
  );
288
+ // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- the error is that thrown value, rethrown below
400
289
  return Effect.runPromiseExitWith(store)(program).then((exit) => {
401
290
  if (operation === "middleware") args.context.set(storeKey, store);
402
291
  if (Exit.isFailure(exit)) throw Cause.squash(exit.cause);
@@ -446,18 +335,32 @@ const wrapModule = (
446
335
  };
447
336
  };
448
337
 
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
- });
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
+ });
461
364
 
462
365
  /** React Router's request handler, as `createRequestHandler` returns it. */
463
366
  type RequestHandler = (request: Request, context: RouterContextProvider) => Promise<Response>;
@@ -473,12 +376,13 @@ interface LoadedBuild {
473
376
  * handler is built once per build value: React Router rebuilds its route
474
377
  * tables on every request when it is given a function, so the adapter
475
378
  * 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.
379
+ * new handler. The last handler is kept across requests, since a development
380
+ * build loads for each request.
478
381
  */
479
382
  const makeBuildLoader = (
480
- options: Options,
481
- mode: string | undefined,
383
+ options: Pick<ReactRouter.Options, "build" | "factory">,
384
+ mode: string,
385
+ instrumentations: ReadonlyArray<ServerInstrumentation>,
482
386
  ): Effect.Effect<LoadedBuild> => {
483
387
  let cached: LoadedBuild | undefined;
484
388
  return Effect.promise(async () => {
@@ -486,7 +390,10 @@ const makeBuildLoader = (
486
390
  if (cached?.build !== build) {
487
391
  cached = {
488
392
  build,
489
- handler: createRequestHandler(wrapBuild(build, options.factory.handlers), mode),
393
+ handler: createRequestHandler(
394
+ wrapBuild(build, options.factory.handlers, instrumentations),
395
+ mode,
396
+ ),
490
397
  };
491
398
  }
492
399
  return cached;
@@ -533,112 +440,217 @@ const callRouter = (
533
440
  });
534
441
  }).pipe(Effect.interruptible);
535
442
 
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;
443
+ // ---------------------------------------------------------------------------
444
+ // The ReactRouter service.
445
+ // ---------------------------------------------------------------------------
542
446
 
543
447
  /**
544
- * The Effect HTTP app {@link make} makes. Serving the client build adds the
545
- * static file server's errors to its errors.
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.
546
452
  */
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
- >;
453
+ const routeMethods = Arr.filter(HttpMethod.all, (method) => method !== "HEAD");
553
454
 
554
455
  /**
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.
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.
559
459
  *
560
460
  * ```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)),
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
+ * ),
572
469
  * );
573
470
  * ```
574
471
  *
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.
472
+ * @stability unstable
581
473
  */
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);
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),
597
600
  });
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
- };
601
+ });
605
602
 
606
- /** The app for one loaded build source, and the client files it serves, if any. */
607
- const serve = (
608
- options: Options,
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,
609
611
  loadBuild: Effect.Effect<LoadedBuild>,
610
- files: ClientFiles | undefined,
611
- ): Effect.Effect<
612
- HttpServerResponse.HttpServerResponse,
613
- HttpServerError.RequestError | HttpServerError.HttpServerError,
614
- HttpServerRequest.HttpServerRequest
615
- > =>
612
+ loadContext: ReactRouter.Options["loadContext"],
613
+ ): ReactRouter["Service"]["handle"] =>
616
614
  Effect.gen(function* () {
617
615
  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
616
  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
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
626
625
  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));
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));
634
637
  const response = yield* callRouter(handler, request, routerContext, controller);
635
- return toHttpResponse(response, 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);
636
648
  }).pipe(
637
649
  // 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.
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.
642
654
  Effect.catchDefect((defect) =>
643
655
  Effect.logError("React Router adapter failed", defect).pipe(
644
656
  Effect.as(HttpServerResponse.empty({ status: 500 })),