react-router-effect 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +373 -117
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +160 -23
- package/dist/{route-data-MgSnnR33.d.mts → route-data-ByiuG-Yb.d.mts} +85 -26
- package/dist/server.d.mts +141 -75
- package/dist/server.mjs +286 -140
- package/dist/vite.d.mts +35 -14
- package/dist/vite.mjs +47 -13
- package/package.json +12 -7
- package/src/assets.ts +212 -0
- package/src/check.ts +9 -1
- package/src/defer.ts +179 -0
- package/src/factory.ts +161 -42
- package/src/index.ts +7 -3
- package/src/route-data.ts +9 -9
- package/src/{action-meta.ts → route-meta.ts} +19 -17
- package/src/server-entry.ts +2 -1
- package/src/server.ts +307 -295
- package/src/vite.ts +109 -32
package/src/server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Serves a React Router framework-mode build from Effect's
|
|
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
|
-
|
|
34
|
+
Layer,
|
|
32
35
|
Option,
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
194
|
-
*
|
|
195
|
-
*
|
|
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
|
|
198
|
-
|
|
199
|
-
)
|
|
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
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
)
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
274
|
-
*
|
|
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
|
|
277
|
-
Tracer.Span | undefined
|
|
278
|
-
>(undefined);
|
|
164
|
+
const httpRoute = (pattern: string): string => (pattern.startsWith("/") ? pattern : `/${pattern}`);
|
|
279
165
|
|
|
280
166
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
:
|
|
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
|
-
/**
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
477
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
? false
|
|
540
|
-
: true
|
|
541
|
-
: false;
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// The ReactRouter service.
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
542
446
|
|
|
543
447
|
/**
|
|
544
|
-
* The
|
|
545
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
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
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
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
|
-
*
|
|
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
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
611
|
-
):
|
|
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
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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 `
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
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 })),
|