react-router-effect 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +286 -108
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +4 -3
- package/dist/{route-data-MgSnnR33.d.mts → route-data-CWBxnNAA.d.mts} +7 -1
- package/dist/server.d.mts +140 -74
- 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 +8 -0
- package/src/factory.ts +20 -10
- package/src/server-entry.ts +2 -1
- package/src/server.ts +307 -295
- package/src/vite.ts +109 -32
package/dist/server.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.mjs";
|
|
2
|
-
import { Cause, Context, Data, Effect, Exit, FileSystem, Option, Path, Scope, Stream, Tracer } from "effect";
|
|
2
|
+
import { Array, Cause, Context, Data, Effect, Exit, FileSystem, Layer, Option, Path, Predicate, Record, Scope, Stream, Struct, Tracer } from "effect";
|
|
3
3
|
import { RouterContextProvider, createContext, createRequestHandler } from "react-router";
|
|
4
|
-
import { Cookies, HttpServerRequest, HttpServerResponse, HttpStaticServer } from "effect/http";
|
|
4
|
+
import { Cookies, HttpMethod, HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse, HttpStaticServer } from "effect/http";
|
|
5
5
|
// src/server.ts
|
|
6
6
|
/**
|
|
7
|
-
* Serves a React Router framework-mode build from Effect's
|
|
7
|
+
* Serves a React Router framework-mode build from Effect's HTTP server.
|
|
8
8
|
*
|
|
9
9
|
* The adapter converts each `HttpServerRequest` into a Web `Request`, calls
|
|
10
10
|
* React Router's own request handler, and streams the `Response` back. React
|
|
@@ -25,17 +25,17 @@ import { Cookies, HttpServerRequest, HttpServerResponse, HttpStaticServer } from
|
|
|
25
25
|
* through unchanged, so existing promise loaders and `makeLoader` wrappers keep
|
|
26
26
|
* working.
|
|
27
27
|
*
|
|
28
|
+
* The adapter's API is unstable: a minor version can change it.
|
|
29
|
+
*
|
|
30
|
+
* @stability unstable
|
|
28
31
|
* @since 2.1.0
|
|
29
32
|
*/
|
|
30
|
-
var server_exports = /* @__PURE__ */ __exportAll({
|
|
31
|
-
ClientBuildError: () => ClientBuildError,
|
|
32
|
-
make: () => make
|
|
33
|
-
});
|
|
34
33
|
/**
|
|
35
34
|
* Converts React Router's response. Its `Set-Cookie` values become the
|
|
36
35
|
* response's cookies, as `HttpServerResponse.fromWeb` converts them, so
|
|
37
36
|
* middleware after the adapter can read and change them. Effect keeps one
|
|
38
|
-
* cookie per name, the last one, and only the attributes it knows.
|
|
37
|
+
* cookie per name, the last one, and only the attributes it knows. A cookie
|
|
38
|
+
* whose name Effect's parser rejects is dropped, and `handleRequest` logs it.
|
|
39
39
|
*
|
|
40
40
|
* Effect gives a streamed body without a content type
|
|
41
41
|
* `application/octet-stream`, so a route that sends a body sets its own.
|
|
@@ -44,14 +44,14 @@ var server_exports = /* @__PURE__ */ __exportAll({
|
|
|
44
44
|
* write failed, the request signal aborts: React Router then stops streamed
|
|
45
45
|
* promises, and route code sees the abort. A completed response never aborts it.
|
|
46
46
|
*/
|
|
47
|
-
const toHttpResponse = (response, controller) => {
|
|
47
|
+
const toHttpResponse = (response, cookies, controller) => {
|
|
48
48
|
const headers = new Headers(response.headers);
|
|
49
49
|
headers.delete("set-cookie");
|
|
50
50
|
const init = {
|
|
51
51
|
status: response.status,
|
|
52
52
|
statusText: response.statusText === "" ? void 0 : response.statusText,
|
|
53
53
|
headers,
|
|
54
|
-
cookies
|
|
54
|
+
cookies
|
|
55
55
|
};
|
|
56
56
|
const body = response.body;
|
|
57
57
|
return body === null ? HttpServerResponse.empty(init) : HttpServerResponse.stream(Stream.fromReadableStream({
|
|
@@ -59,83 +59,54 @@ const toHttpResponse = (response, controller) => {
|
|
|
59
59
|
onError: (error) => error
|
|
60
60
|
}).pipe(Stream.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.sync(() => controller.abort()))), init);
|
|
61
61
|
};
|
|
62
|
-
/** The `Cache-Control` of the files under `assets/`, whose names include a content hash. */
|
|
63
|
-
const hashedCacheControl = "public, max-age=31536000, immutable";
|
|
64
|
-
/** The `Cache-Control` of the other client files, such as `favicon.ico`. */
|
|
65
|
-
const otherCacheControl = "public, max-age=3600";
|
|
66
62
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
63
|
+
* The names of the `Set-Cookie` values that are not in `cookies`, because
|
|
64
|
+
* Effect's parser rejects them, such as a name with `/` or `:`. A value whose
|
|
65
|
+
* name another value repeats is not dropped: Effect keeps the last cookie of
|
|
66
|
+
* each name. Each value is parsed alone only when the counts differ.
|
|
70
67
|
*/
|
|
71
|
-
const
|
|
72
|
-
/** True when a static lookup found no file, so the request goes on to React Router. */
|
|
73
|
-
const isNotFound = (error) => error.reason._tag === "RouteNotFound";
|
|
68
|
+
const droppedCookies = (values, cookies) => Object.keys(cookies.cookies).length === values.length ? [] : values.filter((value) => Cookies.isEmpty(Cookies.fromSetCookie(value))).map((value) => value.split(/[=;]/, 1)[0]?.trim() ?? "");
|
|
74
69
|
/**
|
|
75
|
-
* The
|
|
76
|
-
*
|
|
77
|
-
*
|
|
70
|
+
* The server span of a traced request, which gets `http.route`. Without it,
|
|
71
|
+
* the adapter adds no spans. The `react-router.*` spans nest under the
|
|
72
|
+
* request fiber's own span instead, through the request store.
|
|
78
73
|
*/
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* The client files of a build, or `undefined` when the adapter does not serve
|
|
82
|
-
* them. Making them reads the build's directory, so a missing client build
|
|
83
|
-
* fails the server's start and not its requests.
|
|
84
|
-
*/
|
|
85
|
-
const makeClientFiles = (build) => Effect.gen(function* () {
|
|
86
|
-
if (build.assets.hmr !== void 0 || !build.publicPath.startsWith("/")) return void 0;
|
|
87
|
-
const fileSystem = yield* FileSystem.FileSystem;
|
|
88
|
-
const root = (yield* Path.Path).resolve(build.assetsBuildDirectory);
|
|
89
|
-
yield* fileSystem.readDirectory(root).pipe(Effect.mapError((cause) => new ClientBuildError({
|
|
90
|
-
message: `Cannot read the client build at ${root}`,
|
|
91
|
-
directory: root,
|
|
92
|
-
cause
|
|
93
|
-
})));
|
|
94
|
-
const files = (cacheControl) => HttpStaticServer.make({
|
|
95
|
-
root,
|
|
96
|
-
cacheControl
|
|
97
|
-
}).pipe(Effect.orDie);
|
|
98
|
-
return {
|
|
99
|
-
prefix: build.publicPath.replace(/\/$/, ""),
|
|
100
|
-
hashed: yield* files(hashedCacheControl),
|
|
101
|
-
other: yield* files(otherCacheControl)
|
|
102
|
-
};
|
|
103
|
-
});
|
|
104
|
-
/** The decoded path of a URL, or `undefined` when it does not decode. */
|
|
105
|
-
const decodePath = (path) => {
|
|
106
|
-
try {
|
|
107
|
-
return decodeURIComponent(path);
|
|
108
|
-
} catch {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
};
|
|
74
|
+
const requestSpanKey = createContext(void 0);
|
|
112
75
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
76
|
+
* The server span of the request, when a tracer exports it: not the native
|
|
77
|
+
* tracer, whose spans go nowhere, and not an unsampled span. A route-scoped
|
|
78
|
+
* router middleware can open spans of its own between the request fiber's
|
|
79
|
+
* span and the server span, so the search goes up to the span of kind
|
|
80
|
+
* `server` that Effect's HTTP tracer opens. It runs once per request. The
|
|
81
|
+
* search goes by kind: an app's own span of kind `server` inside the request
|
|
82
|
+
* would get `http.route` instead.
|
|
117
83
|
*/
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (file === void 0 || hasDotSegment(file)) return Effect.succeedNone;
|
|
124
|
-
return (url.startsWith("/assets/") ? files.hashed : files.other).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request.modify({ url })), Effect.map(Option.some), Effect.catchIf(isNotFound, () => Effect.succeedNone));
|
|
84
|
+
const serverSpan = (context) => {
|
|
85
|
+
if (Context.get(context, Tracer.Tracer) === Tracer.nativeTracer) return void 0;
|
|
86
|
+
let span = Context.getOrUndefined(context, Tracer.ParentSpan);
|
|
87
|
+
while (Predicate.isTagged(span, "Span") && span.kind !== "server") span = Option.getOrUndefined(span.parent);
|
|
88
|
+
return Predicate.isTagged(span, "Span") && span.sampled ? span : void 0;
|
|
125
89
|
};
|
|
126
90
|
/**
|
|
127
|
-
* The
|
|
128
|
-
*
|
|
91
|
+
* The `http.route` of a routed pattern, such as `/posts/:id`. React Router
|
|
92
|
+
* joins the route paths, so a root route without a path leaves out the
|
|
93
|
+
* leading slash.
|
|
129
94
|
*/
|
|
130
|
-
const
|
|
95
|
+
const httpRoute = (pattern) => pattern.startsWith("/") ? pattern : `/${pattern}`;
|
|
131
96
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
97
|
+
* Sets `http.route` on the server span to the pattern of the routes that
|
|
98
|
+
* React Router matched, once per request, after React Router answered. React
|
|
99
|
+
* Router 8 gives the pattern to this request handler hook, also for a page
|
|
100
|
+
* that runs no route function. A request that matched no route keeps the
|
|
101
|
+
* router's `*`.
|
|
134
102
|
*/
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
103
|
+
const routeLabel = { handler(handler) {
|
|
104
|
+
handler.instrument({ async request(handleRequest, { context }) {
|
|
105
|
+
const { meta } = await handleRequest();
|
|
106
|
+
const span = context?.get(requestSpanKey);
|
|
107
|
+
if (span !== void 0 && meta !== void 0 && meta.pattern !== "") span.attribute("http.route", httpRoute(meta.pattern));
|
|
108
|
+
} });
|
|
109
|
+
} };
|
|
139
110
|
/** Calls a route function and captures its result, a synchronous throw included. */
|
|
140
111
|
const settle = (call) => {
|
|
141
112
|
try {
|
|
@@ -171,8 +142,7 @@ const isSpanError = (error) => {
|
|
|
171
142
|
};
|
|
172
143
|
/**
|
|
173
144
|
* Runs a route function in a span of its own when the request is traced,
|
|
174
|
-
* such as `react-router.loader routes/home
|
|
175
|
-
* request's span.
|
|
145
|
+
* such as `react-router.loader routes/home`. `routeLabel` sets `http.route`.
|
|
176
146
|
*
|
|
177
147
|
* The spans follow React Router's structure. A middleware's span is the
|
|
178
148
|
* parent of everything its `next()` runs: the next middleware, then the
|
|
@@ -186,12 +156,7 @@ const isSpanError = (error) => {
|
|
|
186
156
|
* middleware's own spans around `next()` take its place there.
|
|
187
157
|
*/
|
|
188
158
|
const traced = (operation, routeId, storeKey, fn) => (args, ...rest) => {
|
|
189
|
-
|
|
190
|
-
if (requestSpan === void 0) return fn(args, ...rest);
|
|
191
|
-
if (typeof args.pattern === "string") {
|
|
192
|
-
const route = args.pattern.startsWith("/") ? args.pattern : `/${args.pattern}`;
|
|
193
|
-
requestSpan.attribute("http.route", route);
|
|
194
|
-
}
|
|
159
|
+
if (args.context.get(requestSpanKey) === void 0) return fn(args, ...rest);
|
|
195
160
|
const store = args.context.get(storeKey);
|
|
196
161
|
const program = Effect.useSpan(`react-router.${operation} ${routeId}`, {
|
|
197
162
|
kind: "internal",
|
|
@@ -199,12 +164,12 @@ const traced = (operation, routeId, storeKey, fn) => (args, ...rest) => {
|
|
|
199
164
|
"react_router.operation.name": operation,
|
|
200
165
|
"react_router.route.id": routeId
|
|
201
166
|
}
|
|
202
|
-
}, (span) => Effect.
|
|
167
|
+
}, (span) => Effect.promise(() => {
|
|
203
168
|
args.context.set(storeKey, Context.add(store, Tracer.ParentSpan, span));
|
|
204
169
|
const settled = settle(() => fn(args, ...rest));
|
|
205
170
|
if (operation !== "middleware") args.context.set(storeKey, store);
|
|
206
171
|
return settled;
|
|
207
|
-
}).pipe(Effect.withParentSpan(span), Effect.flatMap((
|
|
172
|
+
}).pipe(Effect.withParentSpan(span), Effect.flatMap((result) => result.ok || !isSpanError(result.error) ? Effect.succeed(result) : Effect.fail(result.error))));
|
|
208
173
|
return Effect.runPromiseExitWith(store)(program).then((exit) => {
|
|
209
174
|
if (operation === "middleware") args.context.set(storeKey, store);
|
|
210
175
|
if (Exit.isFailure(exit)) throw Cause.squash(exit.cause);
|
|
@@ -225,29 +190,31 @@ const wrapModule = (routeId, module, handlers) => {
|
|
|
225
190
|
middleware: module.middleware?.map((fn) => traced("middleware", routeId, store, handlers.middleware(fn)))
|
|
226
191
|
};
|
|
227
192
|
};
|
|
228
|
-
/**
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Wraps every route module of a build, and adds the adapter's
|
|
195
|
+
* instrumentations after the app's own in the server entry module. An app
|
|
196
|
+
* often does not export `instrumentations`, and `Struct.evolve` changes only
|
|
197
|
+
* the keys that an object has, so the key is set with `Struct.assign`.
|
|
198
|
+
*/
|
|
199
|
+
const wrapBuild = (build, handlers, instrumentations) => Struct.evolve(build, {
|
|
200
|
+
entry: (entry) => Struct.evolve(entry, { module: (module) => Struct.assign(module, { instrumentations: Array.appendAll(module.instrumentations ?? [], instrumentations) }) }),
|
|
201
|
+
routes: (routes) => Record.map(routes, (route, id) => route === void 0 ? void 0 : Struct.evolve(route, { module: (module) => wrapModule(id, module, handlers) }))
|
|
235
202
|
});
|
|
236
203
|
/**
|
|
237
204
|
* Loads the build and returns React Router's request handler for it. The
|
|
238
205
|
* handler is built once per build value: React Router rebuilds its route
|
|
239
206
|
* tables on every request when it is given a function, so the adapter
|
|
240
207
|
* resolves the build itself. In development a new build after a change gets a
|
|
241
|
-
* new handler. The last handler is kept across
|
|
242
|
-
*
|
|
208
|
+
* new handler. The last handler is kept across requests, since a development
|
|
209
|
+
* build loads for each request.
|
|
243
210
|
*/
|
|
244
|
-
const makeBuildLoader = (options, mode) => {
|
|
211
|
+
const makeBuildLoader = (options, mode, instrumentations) => {
|
|
245
212
|
let cached;
|
|
246
213
|
return Effect.promise(async () => {
|
|
247
214
|
const build = typeof options.build === "function" ? await options.build() : options.build;
|
|
248
215
|
if (cached?.build !== build) cached = {
|
|
249
216
|
build,
|
|
250
|
-
handler: createRequestHandler(wrapBuild(build, options.factory.handlers), mode)
|
|
217
|
+
handler: createRequestHandler(wrapBuild(build, options.factory.handlers, instrumentations), mode)
|
|
251
218
|
};
|
|
252
219
|
return cached;
|
|
253
220
|
});
|
|
@@ -279,59 +246,238 @@ const callRouter = (handler, request, context, controller) => Effect.callback((r
|
|
|
279
246
|
});
|
|
280
247
|
}).pipe(Effect.interruptible);
|
|
281
248
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
249
|
+
* The methods of React Router's routes. Not the method `*`, which also
|
|
250
|
+
* registers HEAD: the router answers HEAD with the GET route of a path only
|
|
251
|
+
* when no HEAD route matches it, so a HEAD route on `*` takes HEAD from every
|
|
252
|
+
* GET route of the app.
|
|
253
|
+
*/
|
|
254
|
+
const routeMethods = Array.filter(HttpMethod.all, (method) => method !== "HEAD");
|
|
255
|
+
/**
|
|
256
|
+
* React Router, as a service of the Effect HTTP app. `ReactRouter.layer`
|
|
257
|
+
* provides it, and `ReactRouter.routes` registers it on the app's router as
|
|
258
|
+
* the catch-all route.
|
|
286
259
|
*
|
|
287
260
|
* ```ts
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* // Serve it like any Effect HTTP app, with the app's own middleware.
|
|
297
|
-
* Layer.unwrap(
|
|
298
|
-
* Effect.map(reactRouterApp, (app) => HttpServer.serve(app, HttpMiddleware.logger)),
|
|
261
|
+
* export const App = ReactRouter.routes.pipe(
|
|
262
|
+
* Layer.provide(
|
|
263
|
+
* ReactRouter.layer({
|
|
264
|
+
* build: () => import("virtual:react-router/server-build"),
|
|
265
|
+
* factory: routeFactory,
|
|
266
|
+
* mode: import.meta.env.MODE,
|
|
267
|
+
* }),
|
|
268
|
+
* ),
|
|
299
269
|
* );
|
|
300
270
|
* ```
|
|
301
271
|
*
|
|
302
|
-
*
|
|
303
|
-
* request, and making it does nothing else.
|
|
304
|
-
*
|
|
305
|
-
* The request signal aborts only when the response ends early: when the
|
|
306
|
-
* client leaves while React Router runs or while the body streams. A
|
|
307
|
-
* completed response never aborts it.
|
|
272
|
+
* @stability unstable
|
|
308
273
|
*/
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
274
|
+
var ReactRouter = class ReactRouter extends Context.Service()("react-router-effect/ReactRouter") {
|
|
275
|
+
/**
|
|
276
|
+
* Provides React Router. A build value, or a build function in production
|
|
277
|
+
* mode, loads when the layer builds. Provide one value of this layer to
|
|
278
|
+
* every layer that reads the service: two values load the build twice.
|
|
279
|
+
*
|
|
280
|
+
* @stability unstable
|
|
281
|
+
*/
|
|
282
|
+
static layer = (options) => Layer.effect(ReactRouter, makeReactRouter(options));
|
|
283
|
+
/**
|
|
284
|
+
* Registers React Router as the catch-all route of the app's router, on
|
|
285
|
+
* `*`, for every method of `HttpMethod.all` but HEAD. A route that the app
|
|
286
|
+
* registers itself wins over it, also a route on `/`, such as the
|
|
287
|
+
* prerendered home page of `ReactRouterAssets.layer`. A HEAD request uses
|
|
288
|
+
* the GET route of its path, React Router's or the app's own, as Express
|
|
289
|
+
* does. A method outside `HttpMethod.all` gets the router's 404. Register
|
|
290
|
+
* it at the root of the router: under a prefix, React Router does not get
|
|
291
|
+
* the path that it expects.
|
|
292
|
+
*
|
|
293
|
+
* @stability unstable
|
|
294
|
+
*/
|
|
295
|
+
static routes = HttpRouter.use((router) => Effect.flatMap(ReactRouter, ({ handle }) => router.addAll(routeMethods.map((method) => HttpRouter.route(method, "*", handle)))));
|
|
319
296
|
};
|
|
320
|
-
/**
|
|
321
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Makes the service. A build value, or a build function in production mode,
|
|
299
|
+
* loads once here, so a build that does not load stops the server's start.
|
|
300
|
+
* Any other build loads for each request, and gets a new React Router handler
|
|
301
|
+
* when its value changes.
|
|
302
|
+
*/
|
|
303
|
+
const makeReactRouter = Effect.fnUntraced(function* (options) {
|
|
304
|
+
const mode = options.mode;
|
|
305
|
+
const tracer = yield* Effect.tracer;
|
|
306
|
+
const loadBuild = makeBuildLoader(options, mode, tracer === Tracer.nativeTracer ? [] : [routeLabel]);
|
|
307
|
+
const handlers = options.factory.handlers;
|
|
308
|
+
const services = yield* Effect.context();
|
|
309
|
+
const { loadContext } = options;
|
|
310
|
+
const provided = loadContext === void 0 ? void 0 : (request) => Effect.updateContext(loadContext(request), (requestContext) => Context.merge(services, requestContext));
|
|
311
|
+
if (typeof options.build === "function" && (mode === "development" || mode === "test")) return ReactRouter.of({
|
|
312
|
+
build: Option.none(),
|
|
313
|
+
handle: handleRequest(handlers, loadBuild, provided)
|
|
314
|
+
});
|
|
315
|
+
const loaded = yield* loadBuild;
|
|
316
|
+
return ReactRouter.of({
|
|
317
|
+
build: Option.some(loaded.build),
|
|
318
|
+
handle: handleRequest(handlers, Effect.succeed(loaded), provided)
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
/**
|
|
322
|
+
* Answers the current request with React Router. Route Effects get the
|
|
323
|
+
* request fiber's context without its scope, so they see the services that
|
|
324
|
+
* the app's router middleware provides. A route that needs a scope opens its
|
|
325
|
+
* own with `Effect.scoped`.
|
|
326
|
+
*/
|
|
327
|
+
const handleRequest = (handlers, loadBuild, loadContext) => Effect.gen(function* () {
|
|
322
328
|
const httpRequest = yield* HttpServerRequest.HttpServerRequest;
|
|
323
|
-
if (files !== void 0) {
|
|
324
|
-
const file = yield* serveClientFile(files, httpRequest);
|
|
325
|
-
if (Option.isSome(file)) return file.value;
|
|
326
|
-
}
|
|
327
329
|
const { handler } = yield* loadBuild;
|
|
328
330
|
const fiberContext = Context.omit(Scope.Scope)(yield* Effect.context());
|
|
331
|
+
const routerContext = loadContext === void 0 ? new RouterContextProvider() : yield* loadContext(httpRequest);
|
|
329
332
|
const controller = new AbortController();
|
|
330
|
-
const
|
|
331
|
-
const
|
|
332
|
-
routerContext.set(
|
|
333
|
-
routerContext.set(requestSpanKey,
|
|
333
|
+
const converted = yield* HttpServerRequest.toWeb(httpRequest, { signal: controller.signal });
|
|
334
|
+
const request = converted === httpRequest.source && !converted.bodyUsed && converted.body?.locked !== true ? new Request(converted, { signal: AbortSignal.any([converted.signal, controller.signal]) }) : converted;
|
|
335
|
+
routerContext.set(handlers.requestContext, fiberContext);
|
|
336
|
+
routerContext.set(requestSpanKey, serverSpan(fiberContext));
|
|
334
337
|
const response = yield* callRouter(handler, request, routerContext, controller);
|
|
335
|
-
|
|
338
|
+
const setCookies = response.headers.getSetCookie();
|
|
339
|
+
const cookies = Cookies.fromSetCookie(setCookies);
|
|
340
|
+
const dropped = droppedCookies(setCookies, cookies);
|
|
341
|
+
if (dropped.length > 0) yield* Effect.logError("React Router adapter dropped cookies that Effect cannot send", dropped);
|
|
342
|
+
return toHttpResponse(response, cookies, controller);
|
|
336
343
|
}).pipe(Effect.catchDefect((defect) => Effect.logError("React Router adapter failed", defect).pipe(Effect.as(HttpServerResponse.empty({ status: 500 })))));
|
|
337
|
-
|
|
344
|
+
// src/assets.ts
|
|
345
|
+
/**
|
|
346
|
+
* Serves the client build of a React Router app from the app's Effect HTTP
|
|
347
|
+
* router, as the `express.static` lines of React Router's Express template
|
|
348
|
+
* do.
|
|
349
|
+
*
|
|
350
|
+
* Its API is unstable: a minor version can change it.
|
|
351
|
+
*
|
|
352
|
+
* @stability unstable
|
|
353
|
+
* @since 2.2.0
|
|
354
|
+
*/
|
|
355
|
+
var assets_exports = /* @__PURE__ */ __exportAll({
|
|
356
|
+
ClientBuildError: () => ClientBuildError,
|
|
357
|
+
layer: () => layer
|
|
358
|
+
});
|
|
359
|
+
/**
|
|
360
|
+
* The layer cannot serve the client build: it cannot read the directory, or
|
|
361
|
+
* a file name has a `*`, which the router cannot match. The build's
|
|
362
|
+
* `assetsBuildDirectory` is relative to the working directory of the server,
|
|
363
|
+
* so `directory` is the path that it resolved to.
|
|
364
|
+
*
|
|
365
|
+
* @stability unstable
|
|
366
|
+
* @since 2.2.0
|
|
367
|
+
*/
|
|
368
|
+
var ClientBuildError = class extends Data.TaggedError("ClientBuildError") {};
|
|
369
|
+
/** The `Cache-Control` of the files in the hashed directory, whose names include a content hash. */
|
|
370
|
+
const hashedCacheControl = "public, max-age=31536000, immutable";
|
|
371
|
+
/** The `Cache-Control` of the other files of the client build. */
|
|
372
|
+
const otherCacheControl = "public, max-age=3600";
|
|
373
|
+
/**
|
|
374
|
+
* The index file of a directory. React Router writes a prerendered page as
|
|
375
|
+
* the `index.html` of its path's directory, and it is the default index of
|
|
376
|
+
* `HttpStaticServer.make`.
|
|
377
|
+
*/
|
|
378
|
+
const indexFile = "index.html";
|
|
379
|
+
/**
|
|
380
|
+
* The directory of the hashed files, relative to `publicPath`, from the URL
|
|
381
|
+
* of the manifest that the build plugin writes in it. `None` when the
|
|
382
|
+
* manifest is not under `publicPath`, or is at its root.
|
|
383
|
+
*/
|
|
384
|
+
const hashedDirectory = (build, path) => {
|
|
385
|
+
const manifest = build.assets.url;
|
|
386
|
+
if (!manifest.startsWith(build.publicPath)) return Option.none();
|
|
387
|
+
const directory = path.dirname(manifest.slice(build.publicPath.length - 1));
|
|
388
|
+
return directory === "/" ? Option.none() : Option.some(directory);
|
|
389
|
+
};
|
|
390
|
+
/**
|
|
391
|
+
* The handler of a static server. A missing file is a 404. Any other error,
|
|
392
|
+
* such as a file that the server cannot read, goes to the app's loggers
|
|
393
|
+
* before the 500.
|
|
394
|
+
*/
|
|
395
|
+
const makeHandler = (options) => HttpStaticServer.make(options).pipe(Effect.orDie, Effect.map((files) => files.pipe(Effect.catchReason("HttpServerError", "RouteNotFound", HttpServerRespondable.toResponse), Effect.catch((error) => Effect.logError("Cannot serve a client file", error).pipe(Effect.andThen(HttpServerRespondable.toResponse(error)))))));
|
|
396
|
+
/**
|
|
397
|
+
* Registers the routes of the client build on the app's router, for GET and
|
|
398
|
+
* HEAD, under the build's `publicPath`.
|
|
399
|
+
*
|
|
400
|
+
* The hashed directory is the directory of the build's manifest URL, such as
|
|
401
|
+
* `/assets`. The build plugin writes the files with a content hash in their
|
|
402
|
+
* names there. Its files are cached for a year as immutable, and a request
|
|
403
|
+
* under it for a file that the directory does not have gets a 404. The
|
|
404
|
+
* files are served from that directory only, so a path with `..` does not
|
|
405
|
+
* reach the other files of the client build.
|
|
406
|
+
*
|
|
407
|
+
* Each other file gets its own route, and is cached for an hour. A directory
|
|
408
|
+
* with an `index.html`, such as a prerendered page, gets a route that serves
|
|
409
|
+
* its `index.html`, and its path without the trailing slash redirects. The
|
|
410
|
+
* layer serves no path with a segment that starts with a dot, such as
|
|
411
|
+
* `.vite/`, as `express.static` ignores dotfiles. A root `index.html`, such
|
|
412
|
+
* as a prerendered home page, gets the route `/`, which wins over the
|
|
413
|
+
* catch-all of `ReactRouter.routes`. Any other request goes on to the app's
|
|
414
|
+
* other routes.
|
|
415
|
+
*
|
|
416
|
+
* The layer reads the client build when it builds, so a file that something
|
|
417
|
+
* adds outside the hashed directory later is served after a restart. It
|
|
418
|
+
* fails with a {@link ClientBuildError} when it cannot read the client build
|
|
419
|
+
* directory, or when a file name has a `*`, which the router cannot match.
|
|
420
|
+
* A route of the app with the path of a file fails the server's start.
|
|
421
|
+
*
|
|
422
|
+
* The layer registers nothing for a build that loads for each request, such
|
|
423
|
+
* as a development build, since Vite serves the client there. It also
|
|
424
|
+
* registers nothing for a `publicPath` on another origin.
|
|
425
|
+
*
|
|
426
|
+
* The routes of this layer get the middleware that the app provides to this
|
|
427
|
+
* layer. To leave the server's request logger out of file requests:
|
|
428
|
+
*
|
|
429
|
+
* ```ts
|
|
430
|
+
* ReactRouterAssets.layer.pipe(Layer.provide(HttpRouter.disableLogger))
|
|
431
|
+
* ```
|
|
432
|
+
*
|
|
433
|
+
* @stability unstable
|
|
434
|
+
*/
|
|
435
|
+
const layer = HttpRouter.use((router) => Effect.gen(function* () {
|
|
436
|
+
const { build: loaded } = yield* ReactRouter;
|
|
437
|
+
if (Option.isNone(loaded)) return;
|
|
438
|
+
const build = loaded.value;
|
|
439
|
+
if (!build.publicPath.startsWith("/")) return;
|
|
440
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
441
|
+
const path = yield* Path.Path;
|
|
442
|
+
const root = path.resolve(build.assetsBuildDirectory);
|
|
443
|
+
const readError = (cause) => new ClientBuildError({
|
|
444
|
+
message: `Cannot read the client build at ${root}`,
|
|
445
|
+
directory: root,
|
|
446
|
+
cause
|
|
447
|
+
});
|
|
448
|
+
const entries = yield* fileSystem.readDirectory(root, { recursive: true }).pipe(Effect.mapError(readError));
|
|
449
|
+
const hashed = hashedDirectory(build, path);
|
|
450
|
+
if (Option.isSome(hashed)) {
|
|
451
|
+
const handler = yield* makeHandler({
|
|
452
|
+
root: path.join(root, hashed.value),
|
|
453
|
+
index: void 0,
|
|
454
|
+
cacheControl: hashedCacheControl
|
|
455
|
+
});
|
|
456
|
+
yield* router.prefixed(build.publicPath).prefixed(hashed.value).addAll([HttpRouter.route("GET", "/*", handler), HttpRouter.route("HEAD", "/*", handler)]);
|
|
457
|
+
}
|
|
458
|
+
/** The URL path of an entry of the client build, relative to `publicPath`. */
|
|
459
|
+
const urlPath = (entry) => `/${entry.split(path.sep).join("/")}`;
|
|
460
|
+
const isServed = (url) => !url.split("/").some((segment) => segment.startsWith(".")) && Option.match(hashed, {
|
|
461
|
+
onNone: () => true,
|
|
462
|
+
onSome: (directory) => url !== directory && !url.startsWith(`${directory}/`)
|
|
463
|
+
});
|
|
464
|
+
const files = yield* Effect.filter(entries.filter((entry) => isServed(urlPath(entry))), (entry) => fileSystem.stat(path.join(root, entry)).pipe(Effect.map((info) => info.type === "File"), Effect.mapError(readError)));
|
|
465
|
+
const directories = files.filter((file) => path.basename(file) === indexFile).map((file) => path.dirname(file));
|
|
466
|
+
const urls = Array.appendAll(files.map(urlPath), directories.map((directory) => directory === "." ? "/" : urlPath(directory)));
|
|
467
|
+
const unmatchable = Array.findFirst(urls, (url) => url.includes("*"));
|
|
468
|
+
if (Option.isSome(unmatchable)) return yield* new ClientBuildError({
|
|
469
|
+
message: `Cannot serve ${unmatchable.value} of the client build at ${root}: the router cannot match a * in a path`,
|
|
470
|
+
directory: root,
|
|
471
|
+
cause: void 0
|
|
472
|
+
});
|
|
473
|
+
const handler = yield* makeHandler({
|
|
474
|
+
root,
|
|
475
|
+
index: indexFile,
|
|
476
|
+
cacheControl: otherCacheControl
|
|
477
|
+
});
|
|
478
|
+
yield* (build.publicPath === "/" ? router : router.prefixed(build.publicPath)).addAll(urls.flatMap((url) => {
|
|
479
|
+
const route = `/${url.slice(1).replaceAll(":", "::")}`;
|
|
480
|
+
return [HttpRouter.route("GET", route, handler), HttpRouter.route("HEAD", route, handler)];
|
|
481
|
+
}));
|
|
482
|
+
}));
|
|
483
|
+
export { ReactRouter, assets_exports as ReactRouterAssets };
|
package/dist/vite.d.mts
CHANGED
|
@@ -1,27 +1,48 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Layer, Scope } from "effect";
|
|
2
|
+
import { HttpRouter } from "effect/http";
|
|
3
3
|
import * as Http from "node:http";
|
|
4
4
|
declare namespace vite_d_exports {
|
|
5
|
-
export { ViteDevServer,
|
|
5
|
+
export { ViteDevServer, layer };
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* The parts of a Vite dev server that the adapter uses.
|
|
9
|
+
*
|
|
10
|
+
* @stability unstable
|
|
11
|
+
*/
|
|
8
12
|
interface ViteDevServer {
|
|
13
|
+
/** Vite's Connect middleware stack. */
|
|
9
14
|
readonly middlewares: (request: Http.IncomingMessage, response: Http.ServerResponse, next: (error?: unknown) => void) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Maps the stack of an error to the source files, as the Express template
|
|
17
|
+
* does. Vite's dev server has it.
|
|
18
|
+
*/
|
|
19
|
+
readonly ssrFixStacktrace?: ((error: Error) => void) | undefined;
|
|
10
20
|
}
|
|
11
21
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* ```ts
|
|
16
|
-
* import { ReactRouterVite } from "react-router-effect/vite";
|
|
22
|
+
* Serves the app in development, as the Express template does. Give it to
|
|
23
|
+
* `HttpRouter.serve` in place of the app's `App`:
|
|
17
24
|
*
|
|
25
|
+
* ```js
|
|
18
26
|
* const vite = await createServer({ server: { middlewareMode: true } });
|
|
19
|
-
*
|
|
27
|
+
* const App = ReactRouterVite.layer(vite, () =>
|
|
28
|
+
* vite.ssrLoadModule("./server/app.ts").then((module) => module.App),
|
|
29
|
+
* );
|
|
20
30
|
* ```
|
|
21
31
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
32
|
+
* The layer registers one catch-all route on the server's router. Its
|
|
33
|
+
* handler passes each request to Vite first. For a request that Vite passes
|
|
34
|
+
* on, it calls `loadApp`. When `loadApp` returns a new `App` value, such as
|
|
35
|
+
* after an edit of the app's server code, the layer builds the new `App` with
|
|
36
|
+
* its own router and closes the old one. Concurrent requests share one build
|
|
37
|
+
* of each `App`. Then the request goes through the current `App`.
|
|
38
|
+
*
|
|
39
|
+
* A request that Vite answers gets an empty `HttpServerResponse`, which
|
|
40
|
+
* Effect's Node server does not write, since Vite has ended the Node
|
|
41
|
+
* response. A defect, such as a Vite error, a `loadApp` that rejects or an
|
|
42
|
+
* `App` that fails to build, goes to the app's loggers, after
|
|
43
|
+
* `ssrFixStacktrace` maps its stack, and the client gets an empty 500.
|
|
44
|
+
*
|
|
45
|
+
* @stability unstable
|
|
25
46
|
*/
|
|
26
|
-
declare const
|
|
47
|
+
declare const layer: <E, R>(vite: ViteDevServer, loadApp: () => PromiseLike<Layer.Layer<never, E, R>>) => Layer.Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Error", import("effect/http/HttpServerError").HttpServerError> | Exclude<Exclude<HttpRouter.Request.Without<R>, HttpRouter.HttpRouter>, Scope.Scope> | Exclude<HttpRouter.Request.From<"Error", HttpRouter.Request.Only<"Error", R>>, Scope.Scope> | Exclude<HttpRouter.Request.From<"Error", HttpRouter.Request.Only<"GlobalError", R>>, Scope.Scope> | Exclude<HttpRouter.Request.From<"Requires", Exclude<Exclude<Exclude<Exclude<Exclude<HttpRouter.Request.Without<R>, HttpRouter.HttpRouter>, Scope.Scope>, Layer.CurrentMemoMap>, Exclude<HttpRouter.Request.Without<R>, HttpRouter.HttpRouter>>, HttpRouter.Provided>>, Scope.Scope> | Exclude<HttpRouter.Request.From<"Requires", Exclude<HttpRouter.Request.Only<"GlobalRequires", R>, HttpRouter.Provided>>, Scope.Scope> | Exclude<HttpRouter.Request.From<"Requires", Exclude<HttpRouter.Request.Only<"Requires", R>, HttpRouter.Provided>>, Scope.Scope>>;
|
|
27
48
|
export { vite_d_exports as ReactRouterVite };
|