react-router-effect 1.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +548 -173
- package/dist/index.d.mts +2 -342
- package/dist/index.mjs +122 -34
- package/dist/rolldown-runtime-D8jQpDG7.mjs +12 -0
- package/dist/route-data-MgSnnR33.d.mts +543 -0
- package/dist/server.d.mts +218 -0
- package/dist/server.mjs +337 -0
- package/dist/vite.d.mts +27 -0
- package/dist/vite.mjs +46 -0
- package/package.json +22 -10
- package/src/action-meta.ts +58 -0
- package/src/check.ts +205 -0
- package/src/errors.ts +3 -3
- package/src/factory.ts +291 -84
- package/src/index.ts +22 -1
- package/src/route-data.ts +151 -0
- package/src/server-entry.ts +10 -0
- package/src/server.ts +647 -0
- package/src/vite-entry.ts +2 -0
- package/src/vite.ts +92 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { C as ReturnableDataError, _ as NameError, a as RegisteredMeta, g as LibraryHandled, h as IsAny, m as FailureResponse, o as RegisteredRecover, p as FactoryTypes, y as RouteHandlers } from "./route-data-MgSnnR33.mjs";
|
|
2
|
+
import { Cause, Context, Effect, FileSystem, Path } from "effect";
|
|
3
|
+
import { Register, ServerBuild } from "react-router";
|
|
4
|
+
import { HttpPlatform, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
5
|
+
declare namespace server_d_exports {
|
|
6
|
+
export { App, BuildSource, ClientAssetServices, ClientBuildError, Options, make };
|
|
7
|
+
}
|
|
8
|
+
/** A React Router server build, or a function that loads it, as in `createRequestHandler`. */
|
|
9
|
+
type BuildSource = ServerBuild | (() => ServerBuild | Promise<ServerBuild>);
|
|
10
|
+
/** The options of {@link make}. */
|
|
11
|
+
interface Options {
|
|
12
|
+
/** The server build: `() => import("virtual:react-router/server-build")`. */
|
|
13
|
+
readonly build: BuildSource;
|
|
14
|
+
/** The route factory whose runner, runtime and error model route Effects use. */
|
|
15
|
+
readonly factory: {
|
|
16
|
+
readonly handlers: RouteHandlers;
|
|
17
|
+
};
|
|
18
|
+
/** React Router's server mode. Defaults to `process.env.NODE_ENV`. */
|
|
19
|
+
readonly mode?: string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Serves the client build before React Router: the files of the build's
|
|
22
|
+
* `assetsBuildDirectory`, under its `publicPath`. Files under `assets/` have
|
|
23
|
+
* hashed names and are cached for a year as immutable, other files for an
|
|
24
|
+
* hour. It serves like `express.static`: a directory serves its index file,
|
|
25
|
+
* such as a prerendered page, its path without the trailing slash redirects
|
|
26
|
+
* to the path with it, dotfiles are not served, and a request it does not
|
|
27
|
+
* serve goes to React Router. Like `express.static`, it looks each GET and
|
|
28
|
+
* HEAD request up in the file system. It serves only a build that it loads
|
|
29
|
+
* once: in production mode, or a build value. A
|
|
30
|
+
* development build is never served, since Vite serves the client there, nor
|
|
31
|
+
* is a `publicPath` on another origin. Making the app fails with a
|
|
32
|
+
* {@link ClientBuildError} when it cannot read the directory. Needs the
|
|
33
|
+
* platform's file system, which `NodeHttpServer.layer` and the other server
|
|
34
|
+
* layers provide.
|
|
35
|
+
*/
|
|
36
|
+
readonly clientAssets?: boolean | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Builds the services of one request, before React Router runs. They join
|
|
39
|
+
* the request's effect context, so every middleware, loader and action sees
|
|
40
|
+
* them without a middleware that sets them.
|
|
41
|
+
*/
|
|
42
|
+
readonly requestContext?: ((request: Request) => Context.Context<never>) | undefined;
|
|
43
|
+
}
|
|
44
|
+
/** The services that serving the client build needs. */
|
|
45
|
+
type ClientAssetServices = FileSystem.FileSystem | Path.Path | HttpPlatform.HttpPlatform;
|
|
46
|
+
declare const ClientBuildError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => Cause.YieldableError & {
|
|
47
|
+
readonly _tag: "ClientBuildError";
|
|
48
|
+
} & Readonly<A>;
|
|
49
|
+
/**
|
|
50
|
+
* The app serves the client build, and cannot read its directory. The
|
|
51
|
+
* build's `assetsBuildDirectory` is relative to the working directory of the
|
|
52
|
+
* server, so `directory` is the path it resolved to.
|
|
53
|
+
*/
|
|
54
|
+
declare class ClientBuildError extends ClientBuildError_base<{
|
|
55
|
+
readonly message: string;
|
|
56
|
+
/** The resolved directory of the client build. */
|
|
57
|
+
readonly directory: string;
|
|
58
|
+
readonly cause: unknown;
|
|
59
|
+
}> {}
|
|
60
|
+
/** True when the options serve the client build. */
|
|
61
|
+
type ServesClientAssets<O> = O extends {
|
|
62
|
+
readonly clientAssets: infer Serves;
|
|
63
|
+
} ? [Serves] extends [false | undefined] ? false : true : false;
|
|
64
|
+
/**
|
|
65
|
+
* The Effect HTTP app {@link make} makes. Serving the client build adds the
|
|
66
|
+
* static file server's errors to its errors.
|
|
67
|
+
*/
|
|
68
|
+
type App<O extends Options> = Effect.Effect<HttpServerResponse.HttpServerResponse, HttpServerError.RequestError | (ServesClientAssets<O> extends true ? HttpServerError.HttpServerError : never), HttpServerRequest.HttpServerRequest>;
|
|
69
|
+
/**
|
|
70
|
+
* Makes the Effect HTTP app that serves React Router. Run the Effect once,
|
|
71
|
+
* when the server starts. In production it loads the build, and with
|
|
72
|
+
* `clientAssets` it reads the client build's directory, so a missing one fails
|
|
73
|
+
* the server's start with a {@link ClientBuildError} and not its requests.
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* import { ReactRouterHttp } from "react-router-effect/server";
|
|
77
|
+
*
|
|
78
|
+
* export const reactRouterApp = ReactRouterHttp.make({
|
|
79
|
+
* build: () => import("virtual:react-router/server-build"),
|
|
80
|
+
* factory: routeFactory,
|
|
81
|
+
* clientAssets: true,
|
|
82
|
+
* });
|
|
83
|
+
*
|
|
84
|
+
* // Serve it like any Effect HTTP app, with the app's own middleware.
|
|
85
|
+
* Layer.unwrap(
|
|
86
|
+
* Effect.map(reactRouterApp, (app) => HttpServer.serve(app, HttpMiddleware.logger)),
|
|
87
|
+
* );
|
|
88
|
+
* ```
|
|
89
|
+
*
|
|
90
|
+
* In development, where the build changes, the app loads the build for each
|
|
91
|
+
* request, and making it does nothing else.
|
|
92
|
+
*
|
|
93
|
+
* The request signal aborts only when the response ends early: when the
|
|
94
|
+
* client leaves while React Router runs or while the body streams. A
|
|
95
|
+
* completed response never aborts it.
|
|
96
|
+
*/
|
|
97
|
+
declare const make: <const O extends Options>(options: O) => Effect.Effect<App<O>, ServesClientAssets<O> extends true ? ClientBuildError : never, ServesClientAssets<O> extends true ? ClientAssetServices : never>;
|
|
98
|
+
/** The route modules React Router's typegen registers, keyed by route ID. */
|
|
99
|
+
type RegisteredRouteModules = Register extends {
|
|
100
|
+
readonly routeModules: infer Modules;
|
|
101
|
+
} ? Modules : {};
|
|
102
|
+
/** What an effect middleware may succeed with: React Router's response, or nothing. */
|
|
103
|
+
type MiddlewareResult = Response | void | undefined;
|
|
104
|
+
/** What a failure says to do, under its `react-router-effect` key. */
|
|
105
|
+
type FailureMessage = "This route Effect can fail with an error the route factory does not handle, or needs a service it does not provide. Catch the error in the Effect (for example with Effect.catchTag), add it to the factory's domain errors, or provide the service.";
|
|
106
|
+
/**
|
|
107
|
+
* One export that fails the check. `unhandledErrors` lists each error the
|
|
108
|
+
* effect can fail with that the factory does not handle, by its `_tag`.
|
|
109
|
+
* `missingServices` lists each service that neither the runtime nor the
|
|
110
|
+
* request context provides. `invalidResults` lists what a middleware succeeds
|
|
111
|
+
* with that is not a `Response` or nothing.
|
|
112
|
+
*
|
|
113
|
+
* It is an inline object type, not an interface, so the compiler prints its
|
|
114
|
+
* fields in the error rather than an alias name.
|
|
115
|
+
*/
|
|
116
|
+
type RouteEffectFailure<Route, Export, Unhandled, Missing, Invalid> = {
|
|
117
|
+
readonly "react-router-effect": FailureMessage;
|
|
118
|
+
readonly route: Route;
|
|
119
|
+
readonly export: Export;
|
|
120
|
+
readonly unhandledErrors: Unhandled;
|
|
121
|
+
readonly missingServices: Missing;
|
|
122
|
+
readonly invalidResults: Invalid;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* The failure of one route function, or `never` when it does not return an
|
|
126
|
+
* Effect or when its Effect is fully handled. An Effect whose error or
|
|
127
|
+
* service type is `any` comes from a mistake TypeScript already reports, so it
|
|
128
|
+
* is skipped rather than reported a second time.
|
|
129
|
+
*/
|
|
130
|
+
type FunctionFailure<Route, Export, F, Handled, Provided, Result> = F extends ((...args: never[]) => Effect.Effect<infer A, infer E, infer R>) ? true extends IsAny<E> | IsAny<R> ? never : [Exclude<E, Handled>, Exclude<R, Provided>, Exclude<A, Result>] extends [never, never, never] ? never : {
|
|
131
|
+
readonly "react-router-effect": FailureMessage;
|
|
132
|
+
readonly route: Route;
|
|
133
|
+
readonly export: Export;
|
|
134
|
+
readonly unhandledErrors: NameError<Exclude<E, Handled>>;
|
|
135
|
+
readonly missingServices: Exclude<R, Provided>;
|
|
136
|
+
readonly invalidResults: Exclude<A, Result>;
|
|
137
|
+
} : never;
|
|
138
|
+
/** The failures of one route module's loader, action and middleware. */
|
|
139
|
+
type ModuleFailures<Route, Module, Handled, Provided> = (Module extends {
|
|
140
|
+
readonly loader: infer F;
|
|
141
|
+
} ? FunctionFailure<Route, "loader", F, Handled, Provided, unknown> : never) | (Module extends {
|
|
142
|
+
readonly action: infer F;
|
|
143
|
+
} ? FunctionFailure<Route, "action", F, Handled, Provided, unknown> : never) | (Module extends {
|
|
144
|
+
readonly middleware: ReadonlyArray<infer F>;
|
|
145
|
+
} ? FunctionFailure<Route, "middleware", F, Handled | FailureResponse, Provided, MiddlewareResult> : never);
|
|
146
|
+
/** The failures of every registered route module. */
|
|
147
|
+
type AllFailures<Modules, Handled, Provided> = { [Route in keyof Modules]: ModuleFailures<Route, Modules[Route], Handled, Provided>; }[keyof Modules];
|
|
148
|
+
/** The types of a route factory. */
|
|
149
|
+
type TypesOf<Factory> = Factory extends {
|
|
150
|
+
readonly "~types": infer Types;
|
|
151
|
+
} ? Types : never;
|
|
152
|
+
/** What one error handler's return recovers into, or `never` when it throws. */
|
|
153
|
+
type HandlerRecovered<Return> = Return extends ReturnableDataError<infer Body> ? Body : Return extends Effect.Effect<infer Success, infer _E, infer _R> ? Success : never;
|
|
154
|
+
/** The factory's recover map: each error tag whose handler recovers, and its data. */
|
|
155
|
+
type FactoryRecover<Returns> = { [Tag in keyof Returns as [HandlerRecovered<Returns[Tag]>] extends [never] ? never : Tag]: HandlerRecovered<Returns[Tag]>; };
|
|
156
|
+
/** True when each type is assignable to the other. */
|
|
157
|
+
type SameType<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
158
|
+
/**
|
|
159
|
+
* A disagreement between the `Register` of `react-router-effect` and the
|
|
160
|
+
* factory, which would type plain Effect routes' data wrongly.
|
|
161
|
+
*/
|
|
162
|
+
interface RegisterMismatch<Key, Factory, Registered> {
|
|
163
|
+
readonly register: Key;
|
|
164
|
+
readonly factory: Factory;
|
|
165
|
+
readonly registered: Registered;
|
|
166
|
+
}
|
|
167
|
+
/** The registration mismatches of a factory's types. */
|
|
168
|
+
type RegisterMismatches<Returns, Meta> = (SameType<FactoryRecover<Returns>, RegisteredRecover> extends true ? never : RegisterMismatch<"recover", FactoryRecover<Returns>, RegisteredRecover>) | (SameType<Meta, RegisteredMeta> extends true ? never : RegisterMismatch<"actionMeta", Meta, RegisteredMeta>);
|
|
169
|
+
/** Every failure: the routes', then the registration's. */
|
|
170
|
+
type Failures<Modules, Types> = Types extends FactoryTypes<infer DomainError, infer Returns, infer Services, infer Meta> ? AllFailures<Modules, LibraryHandled<DomainError>, Services> | RegisterMismatches<Returns, Meta> : never;
|
|
171
|
+
/**
|
|
172
|
+
* The failures of every Effect route function across the route modules React
|
|
173
|
+
* Router's typegen registers, and the registration mismatches, as objects.
|
|
174
|
+
* `never` when everything is handled. `RouteEffectCheck` reports them.
|
|
175
|
+
*/
|
|
176
|
+
type RouteEffectFailures<Factory, Modules = RegisteredRouteModules> = Failures<Modules, TypesOf<Factory>>;
|
|
177
|
+
/** A printable name for an error tag or another unhandled error type. */
|
|
178
|
+
type ErrorName<Name> = Name extends string ? Name : "an untagged error";
|
|
179
|
+
/** A printable name for a service: its key, such as `"app/Database"`. */
|
|
180
|
+
type ServiceName<Service> = Service extends {
|
|
181
|
+
readonly key: infer Key extends string;
|
|
182
|
+
} ? Key : "a service without a string key";
|
|
183
|
+
/** The diagnostic lines of one failure, one per error, service or result. */
|
|
184
|
+
type FailureLines<Failure> = Failure extends {
|
|
185
|
+
readonly route: infer Route extends string | number | symbol;
|
|
186
|
+
readonly export: infer Export extends string;
|
|
187
|
+
readonly unhandledErrors: infer Unhandled;
|
|
188
|
+
readonly missingServices: infer Missing;
|
|
189
|
+
readonly invalidResults: infer Invalid;
|
|
190
|
+
} ? (Unhandled extends unknown ? `${Route & string}#${Export} must handle ${ErrorName<Unhandled>}` : never) | (Missing extends unknown ? `${Route & string}#${Export} needs a provider for ${ServiceName<Missing>}` : never) | ([Invalid] extends [never] ? never : `${Route & string}#${Export} must succeed with a Response or nothing`) : Failure extends {
|
|
191
|
+
readonly register: infer Key extends string;
|
|
192
|
+
} ? `the Register of react-router-effect: ${Key} does not match the route factory` : never;
|
|
193
|
+
/**
|
|
194
|
+
* Checks every loader, action and middleware that returns an Effect, across
|
|
195
|
+
* the route modules React Router's typegen registers. Each failure is a
|
|
196
|
+
* required property whose name says what to fix, so assigning `{}` fails to
|
|
197
|
+
* compile with a list such as:
|
|
198
|
+
*
|
|
199
|
+
* ```text
|
|
200
|
+
* Type '{}' is missing the following properties from type ...:
|
|
201
|
+
* "routes/billing#loader must handle PlaidApiError",
|
|
202
|
+
* "routes/admin#middleware needs a provider for app/Session"
|
|
203
|
+
* ```
|
|
204
|
+
*
|
|
205
|
+
* ```ts
|
|
206
|
+
* import type { RouteEffectCheck } from "react-router-effect/server";
|
|
207
|
+
*
|
|
208
|
+
* export const routeEffects: RouteEffectCheck<typeof routeFactory> = {};
|
|
209
|
+
* ```
|
|
210
|
+
*
|
|
211
|
+
* An Effect may fail with the factory's domain errors, the library's `Respond`
|
|
212
|
+
* errors, and `HttpServerRespondable` errors, and require the services of the
|
|
213
|
+
* factory's runtime and request context. A middleware may also fail with what
|
|
214
|
+
* its `next()` fails with. The check also fails when the `Register` of
|
|
215
|
+
* `react-router-effect` disagrees with the factory.
|
|
216
|
+
*/
|
|
217
|
+
type RouteEffectCheck<Factory, Modules = RegisteredRouteModules> = { readonly [Line in FailureLines<RouteEffectFailures<Factory, Modules>>]: Line; };
|
|
218
|
+
export { server_d_exports as ReactRouterHttp, type RegisterMismatch, type RouteEffectCheck, type RouteEffectFailure, type RouteEffectFailures };
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
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";
|
|
3
|
+
import { RouterContextProvider, createContext, createRequestHandler } from "react-router";
|
|
4
|
+
import { Cookies, HttpServerRequest, HttpServerResponse, HttpStaticServer } from "effect/http";
|
|
5
|
+
// src/server.ts
|
|
6
|
+
/**
|
|
7
|
+
* Serves a React Router framework-mode build from Effect's Node 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
|
+
* @since 2.1.0
|
|
29
|
+
*/
|
|
30
|
+
var server_exports = /* @__PURE__ */ __exportAll({
|
|
31
|
+
ClientBuildError: () => ClientBuildError,
|
|
32
|
+
make: () => make
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* Converts React Router's response. Its `Set-Cookie` values become the
|
|
36
|
+
* response's cookies, as `HttpServerResponse.fromWeb` converts them, so
|
|
37
|
+
* 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.
|
|
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, 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: Cookies.fromSetCookie(response.headers.getSetCookie())
|
|
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
|
+
/** 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
|
+
/**
|
|
67
|
+
* True for a path with a segment that starts with a dot, such as `.vite/`,
|
|
68
|
+
* which is never served. Effect's static server serves dotfiles, and Vite
|
|
69
|
+
* writes its manifest there. `express.static` ignores them.
|
|
70
|
+
*/
|
|
71
|
+
const hasDotSegment = (path) => path.split("/").some((segment) => segment.startsWith("."));
|
|
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";
|
|
74
|
+
/**
|
|
75
|
+
* The app serves the client build, and cannot read its directory. The
|
|
76
|
+
* build's `assetsBuildDirectory` is relative to the working directory of the
|
|
77
|
+
* server, so `directory` is the path it resolved to.
|
|
78
|
+
*/
|
|
79
|
+
var ClientBuildError = class extends Data.TaggedError("ClientBuildError") {};
|
|
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
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Serves the request from the client build, or returns `None` when the build
|
|
114
|
+
* has no such file: a path the build does not have, a directory without an
|
|
115
|
+
* index file, a dotfile, a path outside the `publicPath`, or a method other
|
|
116
|
+
* than GET and HEAD.
|
|
117
|
+
*/
|
|
118
|
+
const serveClientFile = (files, request) => {
|
|
119
|
+
const path = request.url.split("?")[0] ?? "";
|
|
120
|
+
if (request.method !== "GET" && request.method !== "HEAD" || !path.startsWith(`${files.prefix}/`)) return Effect.succeedNone;
|
|
121
|
+
const url = request.url.slice(files.prefix.length);
|
|
122
|
+
const file = decodePath(url.split("?")[0] ?? "");
|
|
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));
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* The span of a traced request: the server span of Effect's HTTP tracer, with
|
|
128
|
+
* a tracer that exports spans. Without it, the adapter adds no spans.
|
|
129
|
+
*/
|
|
130
|
+
const requestSpanKey = createContext(void 0);
|
|
131
|
+
/**
|
|
132
|
+
* The span of the request fiber, when a tracer exports it: not the native
|
|
133
|
+
* tracer, whose spans go nowhere, and not an unsampled span.
|
|
134
|
+
*/
|
|
135
|
+
const exportedSpan = (context) => {
|
|
136
|
+
const span = Context.getOrUndefined(context, Tracer.ParentSpan);
|
|
137
|
+
return span?._tag === "Span" && span.sampled && Context.get(context, Tracer.Tracer) !== Tracer.nativeTracer ? span : void 0;
|
|
138
|
+
};
|
|
139
|
+
/** Calls a route function and captures its result, a synchronous throw included. */
|
|
140
|
+
const settle = (call) => {
|
|
141
|
+
try {
|
|
142
|
+
return Promise.resolve(call()).then((value) => ({
|
|
143
|
+
ok: true,
|
|
144
|
+
value
|
|
145
|
+
}), (error) => ({
|
|
146
|
+
ok: false,
|
|
147
|
+
error
|
|
148
|
+
}));
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return Promise.resolve({
|
|
151
|
+
ok: false,
|
|
152
|
+
error
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
/** The status of a thrown `Response` or `data(...)`, which React Router sends as a response. */
|
|
157
|
+
const thrownStatus = (error) => {
|
|
158
|
+
if (error instanceof Response) return error.status;
|
|
159
|
+
if (typeof error !== "object" || error === null || !("type" in error)) return void 0;
|
|
160
|
+
if (error.type !== "DataWithResponseInit" || !("init" in error)) return void 0;
|
|
161
|
+
const init = error.init;
|
|
162
|
+
return typeof init === "object" && init !== null && "status" in init && typeof init.status === "number" ? init.status : 200;
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* A thrown value is an error of the span unless React Router sends it as a
|
|
166
|
+
* response below 500, such as a redirect or `data(..., 404)`.
|
|
167
|
+
*/
|
|
168
|
+
const isSpanError = (error) => {
|
|
169
|
+
const status = thrownStatus(error);
|
|
170
|
+
return status === void 0 || status >= 500;
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Runs a route function in a span of its own when the request is traced,
|
|
174
|
+
* such as `react-router.loader routes/home`, and sets `http.route` on the
|
|
175
|
+
* request's span.
|
|
176
|
+
*
|
|
177
|
+
* The spans follow React Router's structure. A middleware's span is the
|
|
178
|
+
* parent of everything its `next()` runs: the next middleware, then the
|
|
179
|
+
* loaders and the action. A route Effect nests under its function's span, and
|
|
180
|
+
* so do the clients an OpenTelemetry instrumentation traces, since Effect's
|
|
181
|
+
* OpenTelemetry tracer makes the span active while the function is called.
|
|
182
|
+
*
|
|
183
|
+
* The request store's parent span passes the structure down. A loader or an
|
|
184
|
+
* action reads it once, when it is called. A middleware's span stays there
|
|
185
|
+
* for the rest of the chain, until the middleware finishes; an Effect
|
|
186
|
+
* middleware's own spans around `next()` take its place there.
|
|
187
|
+
*/
|
|
188
|
+
const traced = (operation, routeId, storeKey, fn) => (args, ...rest) => {
|
|
189
|
+
const requestSpan = args.context.get(requestSpanKey);
|
|
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
|
+
}
|
|
195
|
+
const store = args.context.get(storeKey);
|
|
196
|
+
const program = Effect.useSpan(`react-router.${operation} ${routeId}`, {
|
|
197
|
+
kind: "internal",
|
|
198
|
+
attributes: {
|
|
199
|
+
"react_router.operation.name": operation,
|
|
200
|
+
"react_router.route.id": routeId
|
|
201
|
+
}
|
|
202
|
+
}, (span) => Effect.sync(() => {
|
|
203
|
+
args.context.set(storeKey, Context.add(store, Tracer.ParentSpan, span));
|
|
204
|
+
const settled = settle(() => fn(args, ...rest));
|
|
205
|
+
if (operation !== "middleware") args.context.set(storeKey, store);
|
|
206
|
+
return settled;
|
|
207
|
+
}).pipe(Effect.withParentSpan(span), Effect.flatMap((settled) => Effect.promise(() => settled)), Effect.flatMap((result) => result.ok || !isSpanError(result.error) ? Effect.succeed(result) : Effect.fail(result.error))));
|
|
208
|
+
return Effect.runPromiseExitWith(store)(program).then((exit) => {
|
|
209
|
+
if (operation === "middleware") args.context.set(storeKey, store);
|
|
210
|
+
if (Exit.isFailure(exit)) throw Cause.squash(exit.cause);
|
|
211
|
+
if (exit.value.ok) return exit.value.value;
|
|
212
|
+
throw exit.value.error;
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* Wraps a route module's loader, action and middleware with the factory's
|
|
217
|
+
* runner, and traces them. The module itself is left as it is.
|
|
218
|
+
*/
|
|
219
|
+
const wrapModule = (routeId, module, handlers) => {
|
|
220
|
+
const store = handlers.requestContext;
|
|
221
|
+
return {
|
|
222
|
+
...module,
|
|
223
|
+
loader: module.loader === void 0 ? void 0 : traced("loader", routeId, store, handlers.loader(module.loader)),
|
|
224
|
+
action: module.action === void 0 ? void 0 : traced("action", routeId, store, handlers.action(module.action)),
|
|
225
|
+
middleware: module.middleware?.map((fn) => traced("middleware", routeId, store, handlers.middleware(fn)))
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
/** Wraps every route module of a build. */
|
|
229
|
+
const wrapBuild = (build, handlers) => ({
|
|
230
|
+
...build,
|
|
231
|
+
routes: Object.fromEntries(Object.entries(build.routes).map(([id, route]) => [id, route === void 0 ? void 0 : {
|
|
232
|
+
...route,
|
|
233
|
+
module: wrapModule(id, route.module, handlers)
|
|
234
|
+
}]))
|
|
235
|
+
});
|
|
236
|
+
/**
|
|
237
|
+
* Loads the build and returns React Router's request handler for it. The
|
|
238
|
+
* handler is built once per build value: React Router rebuilds its route
|
|
239
|
+
* tables on every request when it is given a function, so the adapter
|
|
240
|
+
* resolves the build itself. In development a new build after a change gets a
|
|
241
|
+
* new handler. The last handler is kept across runs of {@link make}'s Effect,
|
|
242
|
+
* since a development server can make the app for each request.
|
|
243
|
+
*/
|
|
244
|
+
const makeBuildLoader = (options, mode) => {
|
|
245
|
+
let cached;
|
|
246
|
+
return Effect.promise(async () => {
|
|
247
|
+
const build = typeof options.build === "function" ? await options.build() : options.build;
|
|
248
|
+
if (cached?.build !== build) cached = {
|
|
249
|
+
build,
|
|
250
|
+
handler: createRequestHandler(wrapBuild(build, options.factory.handlers), mode)
|
|
251
|
+
};
|
|
252
|
+
return cached;
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Calls React Router. The call starts in the request fiber, so code that
|
|
257
|
+
* reads the async context, such as an OpenTelemetry tracer whose context the
|
|
258
|
+
* Effect tracer sets, sees the request's. When the request fiber is
|
|
259
|
+
* interrupted, because the client left, the request signal aborts, and a
|
|
260
|
+
* response that React Router resolves afterwards has its body cancelled.
|
|
261
|
+
*
|
|
262
|
+
* Effect's HTTP server runs the app uninterruptible, and interrupts the fiber
|
|
263
|
+
* only where the app opts in, so the call is interruptible.
|
|
264
|
+
*/
|
|
265
|
+
const callRouter = (handler, request, context, controller) => Effect.callback((resume) => {
|
|
266
|
+
let interrupted = false;
|
|
267
|
+
handler(request, context).then((response) => {
|
|
268
|
+
if (interrupted) {
|
|
269
|
+
response.body?.cancel().catch(() => void 0);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
resume(Effect.succeed(response));
|
|
273
|
+
}, (error) => {
|
|
274
|
+
if (!interrupted) resume(Effect.die(error));
|
|
275
|
+
});
|
|
276
|
+
return Effect.sync(() => {
|
|
277
|
+
interrupted = true;
|
|
278
|
+
controller.abort();
|
|
279
|
+
});
|
|
280
|
+
}).pipe(Effect.interruptible);
|
|
281
|
+
/**
|
|
282
|
+
* Makes the Effect HTTP app that serves React Router. Run the Effect once,
|
|
283
|
+
* when the server starts. In production it loads the build, and with
|
|
284
|
+
* `clientAssets` it reads the client build's directory, so a missing one fails
|
|
285
|
+
* the server's start with a {@link ClientBuildError} and not its requests.
|
|
286
|
+
*
|
|
287
|
+
* ```ts
|
|
288
|
+
* import { ReactRouterHttp } from "react-router-effect/server";
|
|
289
|
+
*
|
|
290
|
+
* export const reactRouterApp = ReactRouterHttp.make({
|
|
291
|
+
* build: () => import("virtual:react-router/server-build"),
|
|
292
|
+
* factory: routeFactory,
|
|
293
|
+
* clientAssets: true,
|
|
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)),
|
|
299
|
+
* );
|
|
300
|
+
* ```
|
|
301
|
+
*
|
|
302
|
+
* In development, where the build changes, the app loads the build for each
|
|
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.
|
|
308
|
+
*/
|
|
309
|
+
const make = (options) => {
|
|
310
|
+
const mode = options.mode ?? process.env.NODE_ENV;
|
|
311
|
+
const loadBuild = makeBuildLoader(options, mode);
|
|
312
|
+
const settled = typeof options.build !== "function" || mode === "production";
|
|
313
|
+
return Effect.gen(function* () {
|
|
314
|
+
if (!settled) return serve(options, loadBuild, void 0);
|
|
315
|
+
const loaded = yield* loadBuild;
|
|
316
|
+
const files = options.clientAssets === true ? yield* makeClientFiles(loaded.build) : void 0;
|
|
317
|
+
return serve(options, Effect.succeed(loaded), files);
|
|
318
|
+
});
|
|
319
|
+
};
|
|
320
|
+
/** The app for one loaded build source, and the client files it serves, if any. */
|
|
321
|
+
const serve = (options, loadBuild, files) => Effect.gen(function* () {
|
|
322
|
+
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
|
+
const { handler } = yield* loadBuild;
|
|
328
|
+
const fiberContext = Context.omit(Scope.Scope)(yield* Effect.context());
|
|
329
|
+
const controller = new AbortController();
|
|
330
|
+
const request = yield* HttpServerRequest.toWeb(httpRequest, { signal: controller.signal });
|
|
331
|
+
const routerContext = new RouterContextProvider();
|
|
332
|
+
routerContext.set(options.factory.handlers.requestContext, Context.merge(fiberContext, options.requestContext?.(request) ?? Context.empty()));
|
|
333
|
+
routerContext.set(requestSpanKey, exportedSpan(fiberContext));
|
|
334
|
+
const response = yield* callRouter(handler, request, routerContext, controller);
|
|
335
|
+
return toHttpResponse(response, controller);
|
|
336
|
+
}).pipe(Effect.catchDefect((defect) => Effect.logError("React Router adapter failed", defect).pipe(Effect.as(HttpServerResponse.empty({ status: 500 })))));
|
|
337
|
+
export { server_exports as ReactRouterHttp };
|
package/dist/vite.d.mts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
3
|
+
import * as Http from "node:http";
|
|
4
|
+
declare namespace vite_d_exports {
|
|
5
|
+
export { ViteDevServer, devMiddleware };
|
|
6
|
+
}
|
|
7
|
+
/** The part of a Vite dev server the middleware uses: its Connect middleware stack. */
|
|
8
|
+
interface ViteDevServer {
|
|
9
|
+
readonly middlewares: (request: Http.IncomingMessage, response: Http.ServerResponse, next: (error?: unknown) => void) => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Passes each request to Vite first, and the requests Vite passes on to the
|
|
13
|
+
* app. Use it in development only, with Vite in middleware mode:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { ReactRouterVite } from "react-router-effect/vite";
|
|
17
|
+
*
|
|
18
|
+
* const vite = await createServer({ server: { middlewareMode: true } });
|
|
19
|
+
* HttpServer.serve(Effect.flatten(reactRouterApp).pipe(ReactRouterVite.devMiddleware(vite)));
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* A request Vite answers gets an empty `HttpServerResponse`, which Effect's
|
|
23
|
+
* Node server does not write, since Vite has ended the Node response. A Vite
|
|
24
|
+
* error fails the request as a defect.
|
|
25
|
+
*/
|
|
26
|
+
declare const devMiddleware: (vite: ViteDevServer) => <E, R>(app: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R | HttpServerRequest.HttpServerRequest>;
|
|
27
|
+
export { vite_d_exports as ReactRouterVite };
|
package/dist/vite.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.mjs";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
4
|
+
import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest";
|
|
5
|
+
// src/vite.ts
|
|
6
|
+
var vite_exports = /* @__PURE__ */ __exportAll({ devMiddleware: () => devMiddleware });
|
|
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
|
+
* Passes each request to Vite first, and the requests Vite passes on to the
|
|
32
|
+
* app. Use it in development only, with Vite in middleware mode:
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { ReactRouterVite } from "react-router-effect/vite";
|
|
36
|
+
*
|
|
37
|
+
* const vite = await createServer({ server: { middlewareMode: true } });
|
|
38
|
+
* HttpServer.serve(Effect.flatten(reactRouterApp).pipe(ReactRouterVite.devMiddleware(vite)));
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* A request Vite answers gets an empty `HttpServerResponse`, which Effect's
|
|
42
|
+
* Node server does not write, since Vite has ended the Node response. A Vite
|
|
43
|
+
* error fails the request as a defect.
|
|
44
|
+
*/
|
|
45
|
+
const devMiddleware = (vite) => (app) => HttpServerRequest.HttpServerRequest.use((request) => passThroughVite(vite, NodeHttpServerRequest.toIncomingMessage(request), NodeHttpServerRequest.toServerResponse(request)).pipe(Effect.flatMap((passed) => passed ? app : Effect.succeed(HttpServerResponse.empty()))));
|
|
46
|
+
export { vite_exports as ReactRouterVite };
|