react-router-effect 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.mjs";
2
+ import { Array, Cause, Context, Data, Effect, Exit, FileSystem, Layer, Option, Path, Predicate, Record, Scope, Stream, Struct, Tracer } from "effect";
3
+ import { RouterContextProvider, createContext, createRequestHandler } from "react-router";
4
+ import { Cookies, HttpMethod, HttpRouter, HttpServerRequest, HttpServerRespondable, HttpServerResponse, HttpStaticServer } from "effect/http";
5
+ // src/server.ts
6
+ /**
7
+ * Serves a React Router framework-mode build from Effect's HTTP server.
8
+ *
9
+ * The adapter converts each `HttpServerRequest` into a Web `Request`, calls
10
+ * React Router's own request handler, and streams the `Response` back. React
11
+ * Router keeps doing everything it does for any other server adapter:
12
+ * documents, `.data` requests, streamed promises, resource routes, the lazy
13
+ * route manifest, and middleware.
14
+ *
15
+ * The adapter owns everything between the Effect HTTP request and React
16
+ * Router: the Web `Request` and its abort signal, the route factory, and the
17
+ * conversion of React Router's `Response`, cookies and streamed body included.
18
+ * Listening, static files, compression, proxies, tracing and the other server
19
+ * concerns stay with the app's Effect HTTP server.
20
+ *
21
+ * Before React Router sees the build, the adapter wraps every loader, action
22
+ * and middleware with the route factory's runner. A route function that
23
+ * returns an Effect runs through the factory's error model, with the factory's
24
+ * runtime and the request's effect context. Any other route function passes
25
+ * through unchanged, so existing promise loaders and `makeLoader` wrappers keep
26
+ * working.
27
+ *
28
+ * The adapter's API is unstable: a minor version can change it.
29
+ *
30
+ * @stability unstable
31
+ * @since 2.1.0
32
+ */
33
+ /**
34
+ * Converts React Router's response. Its `Set-Cookie` values become the
35
+ * response's cookies, as `HttpServerResponse.fromWeb` converts them, so
36
+ * middleware after the adapter can read and change them. Effect keeps one
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
+ *
40
+ * Effect gives a streamed body without a content type
41
+ * `application/octet-stream`, so a route that sends a body sets its own.
42
+ *
43
+ * When the body stream ends before it finished, because the client left or a
44
+ * write failed, the request signal aborts: React Router then stops streamed
45
+ * promises, and route code sees the abort. A completed response never aborts it.
46
+ */
47
+ const toHttpResponse = (response, cookies, controller) => {
48
+ const headers = new Headers(response.headers);
49
+ headers.delete("set-cookie");
50
+ const init = {
51
+ status: response.status,
52
+ statusText: response.statusText === "" ? void 0 : response.statusText,
53
+ headers,
54
+ cookies
55
+ };
56
+ const body = response.body;
57
+ return body === null ? HttpServerResponse.empty(init) : HttpServerResponse.stream(Stream.fromReadableStream({
58
+ evaluate: () => body,
59
+ onError: (error) => error
60
+ }).pipe(Stream.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.sync(() => controller.abort()))), init);
61
+ };
62
+ /**
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.
67
+ */
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() ?? "");
69
+ /**
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.
73
+ */
74
+ const requestSpanKey = createContext(void 0);
75
+ /**
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.
83
+ */
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;
89
+ };
90
+ /**
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.
94
+ */
95
+ const httpRoute = (pattern) => pattern.startsWith("/") ? pattern : `/${pattern}`;
96
+ /**
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 `*`.
102
+ */
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
+ } };
110
+ /** Calls a route function and captures its result, a synchronous throw included. */
111
+ const settle = (call) => {
112
+ try {
113
+ return Promise.resolve(call()).then((value) => ({
114
+ ok: true,
115
+ value
116
+ }), (error) => ({
117
+ ok: false,
118
+ error
119
+ }));
120
+ } catch (error) {
121
+ return Promise.resolve({
122
+ ok: false,
123
+ error
124
+ });
125
+ }
126
+ };
127
+ /** The status of a thrown `Response` or `data(...)`, which React Router sends as a response. */
128
+ const thrownStatus = (error) => {
129
+ if (error instanceof Response) return error.status;
130
+ if (typeof error !== "object" || error === null || !("type" in error)) return void 0;
131
+ if (error.type !== "DataWithResponseInit" || !("init" in error)) return void 0;
132
+ const init = error.init;
133
+ return typeof init === "object" && init !== null && "status" in init && typeof init.status === "number" ? init.status : 200;
134
+ };
135
+ /**
136
+ * A thrown value is an error of the span unless React Router sends it as a
137
+ * response below 500, such as a redirect or `data(..., 404)`.
138
+ */
139
+ const isSpanError = (error) => {
140
+ const status = thrownStatus(error);
141
+ return status === void 0 || status >= 500;
142
+ };
143
+ /**
144
+ * Runs a route function in a span of its own when the request is traced,
145
+ * such as `react-router.loader routes/home`. `routeLabel` sets `http.route`.
146
+ *
147
+ * The spans follow React Router's structure. A middleware's span is the
148
+ * parent of everything its `next()` runs: the next middleware, then the
149
+ * loaders and the action. A route Effect nests under its function's span, and
150
+ * so do the clients an OpenTelemetry instrumentation traces, since Effect's
151
+ * OpenTelemetry tracer makes the span active while the function is called.
152
+ *
153
+ * The request store's parent span passes the structure down. A loader or an
154
+ * action reads it once, when it is called. A middleware's span stays there
155
+ * for the rest of the chain, until the middleware finishes; an Effect
156
+ * middleware's own spans around `next()` take its place there.
157
+ */
158
+ const traced = (operation, routeId, storeKey, fn) => (args, ...rest) => {
159
+ if (args.context.get(requestSpanKey) === void 0) return fn(args, ...rest);
160
+ const store = args.context.get(storeKey);
161
+ const program = Effect.useSpan(`react-router.${operation} ${routeId}`, {
162
+ kind: "internal",
163
+ attributes: {
164
+ "react_router.operation.name": operation,
165
+ "react_router.route.id": routeId
166
+ }
167
+ }, (span) => Effect.promise(() => {
168
+ args.context.set(storeKey, Context.add(store, Tracer.ParentSpan, span));
169
+ const settled = settle(() => fn(args, ...rest));
170
+ if (operation !== "middleware") args.context.set(storeKey, store);
171
+ return settled;
172
+ }).pipe(Effect.withParentSpan(span), Effect.flatMap((result) => result.ok || !isSpanError(result.error) ? Effect.succeed(result) : Effect.fail(result.error))));
173
+ return Effect.runPromiseExitWith(store)(program).then((exit) => {
174
+ if (operation === "middleware") args.context.set(storeKey, store);
175
+ if (Exit.isFailure(exit)) throw Cause.squash(exit.cause);
176
+ if (exit.value.ok) return exit.value.value;
177
+ throw exit.value.error;
178
+ });
179
+ };
180
+ /**
181
+ * Wraps a route module's loader, action and middleware with the factory's
182
+ * runner, and traces them. The module itself is left as it is.
183
+ */
184
+ const wrapModule = (routeId, module, handlers) => {
185
+ const store = handlers.requestContext;
186
+ return {
187
+ ...module,
188
+ loader: module.loader === void 0 ? void 0 : traced("loader", routeId, store, handlers.loader(module.loader)),
189
+ action: module.action === void 0 ? void 0 : traced("action", routeId, store, handlers.action(module.action)),
190
+ middleware: module.middleware?.map((fn) => traced("middleware", routeId, store, handlers.middleware(fn)))
191
+ };
192
+ };
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) }))
202
+ });
203
+ /**
204
+ * Loads the build and returns React Router's request handler for it. The
205
+ * handler is built once per build value: React Router rebuilds its route
206
+ * tables on every request when it is given a function, so the adapter
207
+ * resolves the build itself. In development a new build after a change gets a
208
+ * new handler. The last handler is kept across requests, since a development
209
+ * build loads for each request.
210
+ */
211
+ const makeBuildLoader = (options, mode, instrumentations) => {
212
+ let cached;
213
+ return Effect.promise(async () => {
214
+ const build = typeof options.build === "function" ? await options.build() : options.build;
215
+ if (cached?.build !== build) cached = {
216
+ build,
217
+ handler: createRequestHandler(wrapBuild(build, options.factory.handlers, instrumentations), mode)
218
+ };
219
+ return cached;
220
+ });
221
+ };
222
+ /**
223
+ * Calls React Router. The call starts in the request fiber, so code that
224
+ * reads the async context, such as an OpenTelemetry tracer whose context the
225
+ * Effect tracer sets, sees the request's. When the request fiber is
226
+ * interrupted, because the client left, the request signal aborts, and a
227
+ * response that React Router resolves afterwards has its body cancelled.
228
+ *
229
+ * Effect's HTTP server runs the app uninterruptible, and interrupts the fiber
230
+ * only where the app opts in, so the call is interruptible.
231
+ */
232
+ const callRouter = (handler, request, context, controller) => Effect.callback((resume) => {
233
+ let interrupted = false;
234
+ handler(request, context).then((response) => {
235
+ if (interrupted) {
236
+ response.body?.cancel().catch(() => void 0);
237
+ return;
238
+ }
239
+ resume(Effect.succeed(response));
240
+ }, (error) => {
241
+ if (!interrupted) resume(Effect.die(error));
242
+ });
243
+ return Effect.sync(() => {
244
+ interrupted = true;
245
+ controller.abort();
246
+ });
247
+ }).pipe(Effect.interruptible);
248
+ /**
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.
259
+ *
260
+ * ```ts
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
+ * ),
269
+ * );
270
+ * ```
271
+ *
272
+ * @stability unstable
273
+ */
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)))));
296
+ };
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* () {
328
+ const httpRequest = yield* HttpServerRequest.HttpServerRequest;
329
+ const { handler } = yield* loadBuild;
330
+ const fiberContext = Context.omit(Scope.Scope)(yield* Effect.context());
331
+ const routerContext = loadContext === void 0 ? new RouterContextProvider() : yield* loadContext(httpRequest);
332
+ const controller = new AbortController();
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));
337
+ const response = yield* callRouter(handler, request, routerContext, controller);
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);
343
+ }).pipe(Effect.catchDefect((defect) => Effect.logError("React Router adapter failed", defect).pipe(Effect.as(HttpServerResponse.empty({ status: 500 })))));
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 };
@@ -0,0 +1,48 @@
1
+ import { Layer, Scope } from "effect";
2
+ import { HttpRouter } from "effect/http";
3
+ import * as Http from "node:http";
4
+ declare namespace vite_d_exports {
5
+ export { ViteDevServer, layer };
6
+ }
7
+ /**
8
+ * The parts of a Vite dev server that the adapter uses.
9
+ *
10
+ * @stability unstable
11
+ */
12
+ interface ViteDevServer {
13
+ /** Vite's Connect middleware stack. */
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;
20
+ }
21
+ /**
22
+ * Serves the app in development, as the Express template does. Give it to
23
+ * `HttpRouter.serve` in place of the app's `App`:
24
+ *
25
+ * ```js
26
+ * const vite = await createServer({ server: { middlewareMode: true } });
27
+ * const App = ReactRouterVite.layer(vite, () =>
28
+ * vite.ssrLoadModule("./server/app.ts").then((module) => module.App),
29
+ * );
30
+ * ```
31
+ *
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
46
+ */
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>>;
48
+ export { vite_d_exports as ReactRouterVite };
package/dist/vite.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.mjs";
2
+ import { Effect, Exit, Layer, Option, Predicate, Scope, SynchronizedRef, Tuple } from "effect";
3
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
4
+ import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest";
5
+ // src/vite.ts
6
+ var vite_exports = /* @__PURE__ */ __exportAll({ layer: () => layer });
7
+ /**
8
+ * Runs Vite's middleware on the Node request. Succeeds with `true` when Vite
9
+ * passes the request on, and `false` when Vite answered it, or the client left
10
+ * while Vite answered.
11
+ */
12
+ const passThroughVite = (vite, request, response) => Effect.callback((resume) => {
13
+ let settled = false;
14
+ const settle = (result) => {
15
+ if (settled) return;
16
+ settled = true;
17
+ response.off("finish", answered);
18
+ response.off("close", answered);
19
+ resume(result);
20
+ };
21
+ const answered = () => settle(Effect.succeed(false));
22
+ response.once("finish", answered);
23
+ response.once("close", answered);
24
+ try {
25
+ vite.middlewares(request, response, (error) => settle(error === void 0 ? Effect.succeed(true) : Effect.die(error)));
26
+ } catch (error) {
27
+ settle(Effect.die(error));
28
+ }
29
+ });
30
+ /**
31
+ * Serves the app in development, as the Express template does. Give it to
32
+ * `HttpRouter.serve` in place of the app's `App`:
33
+ *
34
+ * ```js
35
+ * const vite = await createServer({ server: { middlewareMode: true } });
36
+ * const App = ReactRouterVite.layer(vite, () =>
37
+ * vite.ssrLoadModule("./server/app.ts").then((module) => module.App),
38
+ * );
39
+ * ```
40
+ *
41
+ * The layer registers one catch-all route on the server's router. Its
42
+ * handler passes each request to Vite first. For a request that Vite passes
43
+ * on, it calls `loadApp`. When `loadApp` returns a new `App` value, such as
44
+ * after an edit of the app's server code, the layer builds the new `App` with
45
+ * its own router and closes the old one. Concurrent requests share one build
46
+ * of each `App`. Then the request goes through the current `App`.
47
+ *
48
+ * A request that Vite answers gets an empty `HttpServerResponse`, which
49
+ * Effect's Node server does not write, since Vite has ended the Node
50
+ * response. A defect, such as a Vite error, a `loadApp` that rejects or an
51
+ * `App` that fails to build, goes to the app's loggers, after
52
+ * `ssrFixStacktrace` maps its stack, and the client gets an empty 500.
53
+ *
54
+ * @stability unstable
55
+ */
56
+ const layer = (vite, loadApp) => HttpRouter.use((router) => Effect.gen(function* () {
57
+ const layerScope = yield* Effect.scope;
58
+ const services = yield* Effect.context();
59
+ /** Builds `app` in `scope`. The services of this layer do not replace either. */
60
+ const build = (app, scope) => HttpRouter.toHttpEffect(app).pipe(Scope.provide(scope), Effect.provideServiceEffect(Layer.CurrentMemoMap, Layer.makeMemoMap), Effect.provideContext(services));
61
+ const state = yield* SynchronizedRef.make(Option.none());
62
+ /** The handler of `app`, built once for each `App` value. */
63
+ const handlerOf = (app) => SynchronizedRef.modifyEffect(state, (previous) => Effect.gen(function* () {
64
+ if (Option.isSome(previous) && previous.value.app === app) return Tuple.make(previous.value.handler, previous);
65
+ const scope = yield* Scope.fork(layerScope);
66
+ const handler = yield* build(app, scope).pipe(Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Scope.close(scope, exit)), Effect.orDie);
67
+ if (Option.isSome(previous)) yield* Scope.close(previous.value.scope, Exit.void);
68
+ return Tuple.make(handler, Option.some({
69
+ app,
70
+ handler,
71
+ scope
72
+ }));
73
+ }).pipe(Effect.uninterruptible));
74
+ const handler = HttpServerRequest.HttpServerRequest.use((request) => passThroughVite(vite, NodeHttpServerRequest.toIncomingMessage(request), NodeHttpServerRequest.toServerResponse(request)).pipe(Effect.flatMap((passed) => passed ? Effect.promise(loadApp).pipe(Effect.flatMap(handlerOf), Effect.flatten) : Effect.succeed(HttpServerResponse.empty())), Effect.catchDefect((defect) => {
75
+ if (Predicate.isError(defect)) vite.ssrFixStacktrace?.(defect);
76
+ return Effect.logError("Request failed in development", defect).pipe(Effect.as(HttpServerResponse.empty({ status: 500 })));
77
+ })));
78
+ yield* router.add("*", "*", handler);
79
+ }));
80
+ export { vite_exports as ReactRouterVite };