react-router-effect 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +373 -117
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +160 -23
- package/dist/{route-data-MgSnnR33.d.mts → route-data-ByiuG-Yb.d.mts} +85 -26
- package/dist/server.d.mts +141 -75
- package/dist/server.mjs +286 -140
- package/dist/vite.d.mts +35 -14
- package/dist/vite.mjs +47 -13
- package/package.json +12 -7
- package/src/assets.ts +212 -0
- package/src/check.ts +9 -1
- package/src/defer.ts +179 -0
- package/src/factory.ts +161 -42
- package/src/index.ts +7 -3
- package/src/route-data.ts +9 -9
- package/src/{action-meta.ts → route-meta.ts} +19 -17
- package/src/server-entry.ts +2 -1
- package/src/server.ts +307 -295
- package/src/vite.ts +109 -32
package/dist/vite.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.mjs";
|
|
2
|
-
import { Effect } from "effect";
|
|
3
|
-
import { HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
2
|
+
import { Effect, Exit, Layer, Option, Predicate, Scope, SynchronizedRef, Tuple } from "effect";
|
|
3
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
4
4
|
import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest";
|
|
5
5
|
// src/vite.ts
|
|
6
|
-
var vite_exports = /* @__PURE__ */ __exportAll({
|
|
6
|
+
var vite_exports = /* @__PURE__ */ __exportAll({ layer: () => layer });
|
|
7
7
|
/**
|
|
8
8
|
* Runs Vite's middleware on the Node request. Succeeds with `true` when Vite
|
|
9
9
|
* passes the request on, and `false` when Vite answered it, or the client left
|
|
@@ -28,19 +28,53 @@ const passThroughVite = (vite, request, response) => Effect.callback((resume) =>
|
|
|
28
28
|
}
|
|
29
29
|
});
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* ```ts
|
|
35
|
-
* import { ReactRouterVite } from "react-router-effect/vite";
|
|
31
|
+
* Serves the app in development, as the Express template does. Give it to
|
|
32
|
+
* `HttpRouter.serve` in place of the app's `App`:
|
|
36
33
|
*
|
|
34
|
+
* ```js
|
|
37
35
|
* const vite = await createServer({ server: { middlewareMode: true } });
|
|
38
|
-
*
|
|
36
|
+
* const App = ReactRouterVite.layer(vite, () =>
|
|
37
|
+
* vite.ssrLoadModule("./server/app.ts").then((module) => module.App),
|
|
38
|
+
* );
|
|
39
39
|
* ```
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
44
55
|
*/
|
|
45
|
-
const
|
|
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
|
+
}));
|
|
46
80
|
export { vite_exports as ReactRouterVite };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://schemastore.org/package.json",
|
|
3
3
|
"name": "react-router-effect",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.3.0",
|
|
5
5
|
"description": "Wrap React Router framework-mode route modules with Effect, with typed recover/throw/redirect error handling.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"action",
|
|
@@ -40,22 +40,24 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@changesets/changelog-github": "^1.0.1",
|
|
42
42
|
"@changesets/cli": "^3.0.3",
|
|
43
|
-
"@effect/platform-node": "4.1.
|
|
43
|
+
"@effect/platform-node": "4.1.2",
|
|
44
|
+
"@effect/tsgo": "0.67.5",
|
|
44
45
|
"@react-router/node": "8.4.0",
|
|
45
46
|
"@types/node": "^25.6.2",
|
|
46
47
|
"@types/react": "^19.2.17",
|
|
47
48
|
"@types/react-dom": "^19.2.3",
|
|
48
49
|
"@vitejs/plugin-react": "^6.0.4",
|
|
49
|
-
"@vitest/browser": "5.0
|
|
50
|
-
"@vitest/browser-playwright": "5.0
|
|
51
|
-
"effect": "4.1.
|
|
50
|
+
"@vitest/browser": "5.1.0",
|
|
51
|
+
"@vitest/browser-playwright": "5.1.0",
|
|
52
|
+
"effect": "4.1.2",
|
|
53
|
+
"miniflare": "5.20261104.0-beta",
|
|
52
54
|
"playwright": "1.64.1",
|
|
53
55
|
"react": "^19.2.8",
|
|
54
56
|
"react-dom": "^19.2.8",
|
|
55
57
|
"react-router": "^8.3.0",
|
|
56
58
|
"typescript": "^7.0.2",
|
|
57
59
|
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
|
58
|
-
"vite-plus": "^1.
|
|
60
|
+
"vite-plus": "^1.5.0",
|
|
59
61
|
"vitest": "5.0.5",
|
|
60
62
|
"vitest-browser-react": "^2.2.0"
|
|
61
63
|
},
|
|
@@ -76,6 +78,9 @@
|
|
|
76
78
|
"onFail": "warn"
|
|
77
79
|
}
|
|
78
80
|
},
|
|
81
|
+
"engines": {
|
|
82
|
+
"node": ">=22"
|
|
83
|
+
},
|
|
79
84
|
"scripts": {
|
|
80
85
|
"build": "vp pack",
|
|
81
86
|
"dev": "vp pack --watch",
|
|
@@ -83,7 +88,7 @@
|
|
|
83
88
|
"test:unit": "vp test run --project unit",
|
|
84
89
|
"test:browser": "vp test run --project browser",
|
|
85
90
|
"test:browser:install": "vp exec playwright install chromium",
|
|
86
|
-
"check": "vp check",
|
|
91
|
+
"check": "effect-tsgo vp check",
|
|
87
92
|
"check:consumer": "tsc -p test/consumer",
|
|
88
93
|
"changeset": "changeset",
|
|
89
94
|
"changeset:version": "changeset version",
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves the client build of a React Router app from the app's Effect HTTP
|
|
3
|
+
* router, as the `express.static` lines of React Router's Express template
|
|
4
|
+
* do.
|
|
5
|
+
*
|
|
6
|
+
* Its API is unstable: a minor version can change it.
|
|
7
|
+
*
|
|
8
|
+
* @stability unstable
|
|
9
|
+
* @since 2.2.0
|
|
10
|
+
*/
|
|
11
|
+
import { Array as Arr, Data, Effect, FileSystem, type Layer, Option, Path } from "effect";
|
|
12
|
+
import {
|
|
13
|
+
type HttpPlatform,
|
|
14
|
+
HttpRouter,
|
|
15
|
+
HttpServerRespondable,
|
|
16
|
+
HttpStaticServer,
|
|
17
|
+
} from "effect/http";
|
|
18
|
+
import type { ServerBuild } from "react-router";
|
|
19
|
+
|
|
20
|
+
import { ReactRouter } from "./server.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The layer cannot serve the client build: it cannot read the directory, or
|
|
24
|
+
* a file name has a `*`, which the router cannot match. The build's
|
|
25
|
+
* `assetsBuildDirectory` is relative to the working directory of the server,
|
|
26
|
+
* so `directory` is the path that it resolved to.
|
|
27
|
+
*
|
|
28
|
+
* @stability unstable
|
|
29
|
+
* @since 2.2.0
|
|
30
|
+
*/
|
|
31
|
+
export class ClientBuildError extends Data.TaggedError("ClientBuildError")<{
|
|
32
|
+
readonly message: string;
|
|
33
|
+
/** The resolved directory of the client build. */
|
|
34
|
+
readonly directory: string;
|
|
35
|
+
readonly cause: unknown;
|
|
36
|
+
}> {}
|
|
37
|
+
|
|
38
|
+
/** The `Cache-Control` of the files in the hashed directory, whose names include a content hash. */
|
|
39
|
+
const hashedCacheControl = "public, max-age=31536000, immutable";
|
|
40
|
+
|
|
41
|
+
/** The `Cache-Control` of the other files of the client build. */
|
|
42
|
+
const otherCacheControl = "public, max-age=3600";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The index file of a directory. React Router writes a prerendered page as
|
|
46
|
+
* the `index.html` of its path's directory, and it is the default index of
|
|
47
|
+
* `HttpStaticServer.make`.
|
|
48
|
+
*/
|
|
49
|
+
const indexFile = "index.html";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The directory of the hashed files, relative to `publicPath`, from the URL
|
|
53
|
+
* of the manifest that the build plugin writes in it. `None` when the
|
|
54
|
+
* manifest is not under `publicPath`, or is at its root.
|
|
55
|
+
*/
|
|
56
|
+
const hashedDirectory = (build: ServerBuild, path: Path.Path): Option.Option<string> => {
|
|
57
|
+
const manifest = build.assets.url;
|
|
58
|
+
if (!manifest.startsWith(build.publicPath)) return Option.none();
|
|
59
|
+
const directory = path.dirname(manifest.slice(build.publicPath.length - 1));
|
|
60
|
+
return directory === "/" ? Option.none() : Option.some(directory);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The handler of a static server. A missing file is a 404. Any other error,
|
|
65
|
+
* such as a file that the server cannot read, goes to the app's loggers
|
|
66
|
+
* before the 500.
|
|
67
|
+
*/
|
|
68
|
+
const makeHandler = (options: {
|
|
69
|
+
readonly root: string;
|
|
70
|
+
readonly index: string | undefined;
|
|
71
|
+
readonly cacheControl: string;
|
|
72
|
+
}) =>
|
|
73
|
+
HttpStaticServer.make(options).pipe(
|
|
74
|
+
// Making a static server only resolves its root.
|
|
75
|
+
Effect.orDie,
|
|
76
|
+
Effect.map((files) =>
|
|
77
|
+
files.pipe(
|
|
78
|
+
Effect.catchReason("HttpServerError", "RouteNotFound", HttpServerRespondable.toResponse),
|
|
79
|
+
Effect.catch((error) =>
|
|
80
|
+
Effect.logError("Cannot serve a client file", error).pipe(
|
|
81
|
+
Effect.andThen(HttpServerRespondable.toResponse(error)),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Registers the routes of the client build on the app's router, for GET and
|
|
90
|
+
* HEAD, under the build's `publicPath`.
|
|
91
|
+
*
|
|
92
|
+
* The hashed directory is the directory of the build's manifest URL, such as
|
|
93
|
+
* `/assets`. The build plugin writes the files with a content hash in their
|
|
94
|
+
* names there. Its files are cached for a year as immutable, and a request
|
|
95
|
+
* under it for a file that the directory does not have gets a 404. The
|
|
96
|
+
* files are served from that directory only, so a path with `..` does not
|
|
97
|
+
* reach the other files of the client build.
|
|
98
|
+
*
|
|
99
|
+
* Each other file gets its own route, and is cached for an hour. A directory
|
|
100
|
+
* with an `index.html`, such as a prerendered page, gets a route that serves
|
|
101
|
+
* its `index.html`, and its path without the trailing slash redirects. The
|
|
102
|
+
* layer serves no path with a segment that starts with a dot, such as
|
|
103
|
+
* `.vite/`, as `express.static` ignores dotfiles. A root `index.html`, such
|
|
104
|
+
* as a prerendered home page, gets the route `/`, which wins over the
|
|
105
|
+
* catch-all of `ReactRouter.routes`. Any other request goes on to the app's
|
|
106
|
+
* other routes.
|
|
107
|
+
*
|
|
108
|
+
* The layer reads the client build when it builds, so a file that something
|
|
109
|
+
* adds outside the hashed directory later is served after a restart. It
|
|
110
|
+
* fails with a {@link ClientBuildError} when it cannot read the client build
|
|
111
|
+
* directory, or when a file name has a `*`, which the router cannot match.
|
|
112
|
+
* A route of the app with the path of a file fails the server's start.
|
|
113
|
+
*
|
|
114
|
+
* The layer registers nothing for a build that loads for each request, such
|
|
115
|
+
* as a development build, since Vite serves the client there. It also
|
|
116
|
+
* registers nothing for a `publicPath` on another origin.
|
|
117
|
+
*
|
|
118
|
+
* The routes of this layer get the middleware that the app provides to this
|
|
119
|
+
* layer. To leave the server's request logger out of file requests:
|
|
120
|
+
*
|
|
121
|
+
* ```ts
|
|
122
|
+
* ReactRouterAssets.layer.pipe(Layer.provide(HttpRouter.disableLogger))
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* @stability unstable
|
|
126
|
+
*/
|
|
127
|
+
export const layer: Layer.Layer<
|
|
128
|
+
never,
|
|
129
|
+
ClientBuildError,
|
|
130
|
+
| HttpRouter.HttpRouter
|
|
131
|
+
| ReactRouter
|
|
132
|
+
| FileSystem.FileSystem
|
|
133
|
+
| Path.Path
|
|
134
|
+
| HttpPlatform.HttpPlatform
|
|
135
|
+
> = HttpRouter.use((router) =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const { build: loaded } = yield* ReactRouter;
|
|
138
|
+
if (Option.isNone(loaded)) return;
|
|
139
|
+
const build = loaded.value;
|
|
140
|
+
if (!build.publicPath.startsWith("/")) return;
|
|
141
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
142
|
+
const path = yield* Path.Path;
|
|
143
|
+
const root = path.resolve(build.assetsBuildDirectory);
|
|
144
|
+
const readError = (cause: unknown) =>
|
|
145
|
+
new ClientBuildError({
|
|
146
|
+
message: `Cannot read the client build at ${root}`,
|
|
147
|
+
directory: root,
|
|
148
|
+
cause,
|
|
149
|
+
});
|
|
150
|
+
const entries = yield* fileSystem
|
|
151
|
+
.readDirectory(root, { recursive: true })
|
|
152
|
+
.pipe(Effect.mapError(readError));
|
|
153
|
+
const hashed = hashedDirectory(build, path);
|
|
154
|
+
|
|
155
|
+
if (Option.isSome(hashed)) {
|
|
156
|
+
const handler = yield* makeHandler({
|
|
157
|
+
root: path.join(root, hashed.value),
|
|
158
|
+
index: undefined,
|
|
159
|
+
cacheControl: hashedCacheControl,
|
|
160
|
+
});
|
|
161
|
+
yield* router
|
|
162
|
+
.prefixed(build.publicPath)
|
|
163
|
+
.prefixed(hashed.value)
|
|
164
|
+
.addAll([HttpRouter.route("GET", "/*", handler), HttpRouter.route("HEAD", "/*", handler)]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The URL path of an entry of the client build, relative to `publicPath`. */
|
|
168
|
+
const urlPath = (entry: string): HttpRouter.PathInput => `/${entry.split(path.sep).join("/")}`;
|
|
169
|
+
const isServed = (url: string) =>
|
|
170
|
+
!url.split("/").some((segment) => segment.startsWith(".")) &&
|
|
171
|
+
Option.match(hashed, {
|
|
172
|
+
onNone: () => true,
|
|
173
|
+
onSome: (directory) => url !== directory && !url.startsWith(`${directory}/`),
|
|
174
|
+
});
|
|
175
|
+
const files = yield* Effect.filter(
|
|
176
|
+
entries.filter((entry) => isServed(urlPath(entry))),
|
|
177
|
+
(entry) =>
|
|
178
|
+
fileSystem.stat(path.join(root, entry)).pipe(
|
|
179
|
+
Effect.map((info) => info.type === "File"),
|
|
180
|
+
Effect.mapError(readError),
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
const directories = files
|
|
184
|
+
.filter((file) => path.basename(file) === indexFile)
|
|
185
|
+
.map((file) => path.dirname(file));
|
|
186
|
+
const urls = Arr.appendAll(
|
|
187
|
+
files.map(urlPath),
|
|
188
|
+
// The root directory is `.`, whose URL path is `/`.
|
|
189
|
+
directories.map((directory) => (directory === "." ? "/" : urlPath(directory))),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const unmatchable = Arr.findFirst(urls, (url) => url.includes("*"));
|
|
193
|
+
if (Option.isSome(unmatchable)) {
|
|
194
|
+
return yield* new ClientBuildError({
|
|
195
|
+
message: `Cannot serve ${unmatchable.value} of the client build at ${root}: the router cannot match a * in a path`,
|
|
196
|
+
directory: root,
|
|
197
|
+
cause: undefined,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const handler = yield* makeHandler({ root, index: indexFile, cacheControl: otherCacheControl });
|
|
202
|
+
// A prefix of `/` changes the request URL on every request for nothing.
|
|
203
|
+
const underPublicPath = build.publicPath === "/" ? router : router.prefixed(build.publicPath);
|
|
204
|
+
yield* underPublicPath.addAll(
|
|
205
|
+
urls.flatMap((url) => {
|
|
206
|
+
// The router reads `:` as the start of a parameter, unless it is doubled.
|
|
207
|
+
const route: HttpRouter.PathInput = `/${url.slice(1).replaceAll(":", "::")}`;
|
|
208
|
+
return [HttpRouter.route("GET", route, handler), HttpRouter.route("HEAD", route, handler)];
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}),
|
|
212
|
+
);
|
package/src/check.ts
CHANGED
|
@@ -30,6 +30,8 @@ type FailureMessage =
|
|
|
30
30
|
*
|
|
31
31
|
* It is an inline object type, not an interface, so the compiler prints its
|
|
32
32
|
* fields in the error rather than an alias name.
|
|
33
|
+
*
|
|
34
|
+
* @stability unstable
|
|
33
35
|
*/
|
|
34
36
|
export type RouteEffectFailure<Route, Export, Unhandled, Missing, Invalid> = {
|
|
35
37
|
readonly "react-router-effect": FailureMessage;
|
|
@@ -112,6 +114,8 @@ type SameType<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false
|
|
|
112
114
|
/**
|
|
113
115
|
* A disagreement between the `Register` of `react-router-effect` and the
|
|
114
116
|
* factory, which would type plain Effect routes' data wrongly.
|
|
117
|
+
*
|
|
118
|
+
* @stability unstable
|
|
115
119
|
*/
|
|
116
120
|
export interface RegisterMismatch<Key, Factory, Registered> {
|
|
117
121
|
readonly register: Key;
|
|
@@ -126,7 +130,7 @@ type RegisterMismatches<Returns, Meta> =
|
|
|
126
130
|
: RegisterMismatch<"recover", FactoryRecover<Returns>, RegisteredRecover>)
|
|
127
131
|
| (SameType<Meta, RegisteredMeta> extends true
|
|
128
132
|
? never
|
|
129
|
-
: RegisterMismatch<"
|
|
133
|
+
: RegisterMismatch<"routeMeta", Meta, RegisteredMeta>);
|
|
130
134
|
|
|
131
135
|
/** Every failure: the routes', then the registration's. */
|
|
132
136
|
type Failures<Modules, Types> =
|
|
@@ -140,6 +144,8 @@ type Failures<Modules, Types> =
|
|
|
140
144
|
* The failures of every Effect route function across the route modules React
|
|
141
145
|
* Router's typegen registers, and the registration mismatches, as objects.
|
|
142
146
|
* `never` when everything is handled. `RouteEffectCheck` reports them.
|
|
147
|
+
*
|
|
148
|
+
* @stability unstable
|
|
143
149
|
*/
|
|
144
150
|
export type RouteEffectFailures<Factory, Modules = RegisteredRouteModules> = Failures<
|
|
145
151
|
Modules,
|
|
@@ -199,6 +205,8 @@ type FailureLines<Failure> = Failure extends {
|
|
|
199
205
|
* factory's runtime and request context. A middleware may also fail with what
|
|
200
206
|
* its `next()` fails with. The check also fails when the `Register` of
|
|
201
207
|
* `react-router-effect` disagrees with the factory.
|
|
208
|
+
*
|
|
209
|
+
* @stability unstable
|
|
202
210
|
*/
|
|
203
211
|
export type RouteEffectCheck<Factory, Modules = RegisteredRouteModules> = {
|
|
204
212
|
readonly [Line in FailureLines<RouteEffectFailures<Factory, Modules>>]: Line;
|
package/src/defer.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Cause, Context, Effect, Exit, Option, Scope } from "effect";
|
|
2
|
+
import {
|
|
3
|
+
data,
|
|
4
|
+
UNSAFE_ErrorResponseImpl as ErrorResponseImpl,
|
|
5
|
+
type ErrorResponse,
|
|
6
|
+
type UNSAFE_DataWithResponseInit as DataWithResponseInit,
|
|
7
|
+
} from "react-router";
|
|
8
|
+
|
|
9
|
+
import { ReturnableDataError, ThrowableDataError, type AnyRouteError } from "./errors.ts";
|
|
10
|
+
import type { FailureResponse, IsAny, NameError } from "./factory.ts";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// The scope of deferred work.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The scope that deferred work forks into. The route runner sets it for each
|
|
18
|
+
* loader or action run, and closes it when the request aborts or the route
|
|
19
|
+
* Effect fails. Outside a route runner it is unset, and deferred work runs
|
|
20
|
+
* detached.
|
|
21
|
+
*/
|
|
22
|
+
export const DeferScope = Context.Reference<Scope.Scope | undefined>(
|
|
23
|
+
"react-router-effect/DeferScope",
|
|
24
|
+
{ defaultValue: () => undefined },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Gives a route Effect a scope for its deferred work. The scope closes, and
|
|
29
|
+
* interrupts the deferred fibers, when `signal` aborts or the Effect fails: a
|
|
30
|
+
* failed loader sends no data, so no `<Await>` waits for the work.
|
|
31
|
+
*/
|
|
32
|
+
export const withDeferScope = <A, E, R>(
|
|
33
|
+
effect: Effect.Effect<A, E, R>,
|
|
34
|
+
signal: AbortSignal,
|
|
35
|
+
): Effect.Effect<A, E, R> =>
|
|
36
|
+
Effect.suspend(() => {
|
|
37
|
+
const scope = Scope.makeUnsafe("parallel");
|
|
38
|
+
const close = <X, Y>(exit: Exit.Exit<X, Y>) => Scope.close(scope, exit);
|
|
39
|
+
signal.addEventListener("abort", () => Effect.runFork(close(Exit.interrupt())), {
|
|
40
|
+
once: true,
|
|
41
|
+
});
|
|
42
|
+
return effect.pipe(
|
|
43
|
+
Effect.provideService(DeferScope, scope),
|
|
44
|
+
Effect.onError((cause) => close(Exit.failCause(cause))),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// The deferred promise.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The compile-time diagnostic of the factory's `defer`. A deferred Effect
|
|
54
|
+
* runs after the response started, so a failure cannot change the response.
|
|
55
|
+
* The Effect must handle every error it can fail with before it is deferred,
|
|
56
|
+
* except the `Handled` ones, which the error handlers turn into a value or an
|
|
57
|
+
* `ErrorResponse`. Resolves to `unknown` (a no-op intersection) when every
|
|
58
|
+
* error is handled.
|
|
59
|
+
*
|
|
60
|
+
* @stability unstable
|
|
61
|
+
*/
|
|
62
|
+
export type DeferDiagnose<E, Handled> =
|
|
63
|
+
IsAny<E> extends true
|
|
64
|
+
? unknown
|
|
65
|
+
: [Exclude<E, Handled>] extends [never]
|
|
66
|
+
? unknown
|
|
67
|
+
: {
|
|
68
|
+
"react-router-effect": "A deferred effect runs after the response started, so it can fail only with the factory's domain errors, Respond.early and Respond.throw. Handle the other errors before defer: recover them into data (e.g. Effect.catchTag), or make them defects with Effect.orDie.";
|
|
69
|
+
unhandledErrors: NameError<Exclude<E, Handled>>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The promise that the factory's `defer` gives: a `Promise` of the deferred Effect's
|
|
74
|
+
* success, which React Router streams to `<Await>`. The work started when the
|
|
75
|
+
* Effect was deferred.
|
|
76
|
+
*
|
|
77
|
+
* It is a type of its own so that an Effect that succeeds with it states
|
|
78
|
+
* that the promise is not awaited on purpose. The language service's
|
|
79
|
+
* `promiseInEffectSuccess` rule reports only the global `Promise` type.
|
|
80
|
+
*
|
|
81
|
+
* @stability unstable
|
|
82
|
+
*/
|
|
83
|
+
export interface DeferredPromise<A> extends Promise<A> {}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The message every deferred rejection has. The server logs the cause.
|
|
87
|
+
*
|
|
88
|
+
* @stability unstable
|
|
89
|
+
*/
|
|
90
|
+
export const deferredFailureMessage = "Deferred route data failed";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The rejection the browser gets for a defect or an interrupt: a constant
|
|
94
|
+
* message and no stack, in every server mode. React Router removes server
|
|
95
|
+
* details only in production.
|
|
96
|
+
*/
|
|
97
|
+
const deferredFailure = (): Error => {
|
|
98
|
+
const error = new Error(deferredFailureMessage);
|
|
99
|
+
error.stack = undefined;
|
|
100
|
+
return error;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Forks `self` into the run's defer scope and gives a promise of its result.
|
|
105
|
+
* A failure rejects the promise with its `ErrorResponse`, which React Router
|
|
106
|
+
* streams to `useAsyncError`. A defect or interrupt rejects it with
|
|
107
|
+
* `deferredFailure`, and the server logs a defect's full cause.
|
|
108
|
+
*/
|
|
109
|
+
export const forkDeferred = <A, R>(
|
|
110
|
+
self: Effect.Effect<A, ErrorResponse, R>,
|
|
111
|
+
): Effect.Effect<DeferredPromise<A>, never, R> =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const scope = yield* DeferScope;
|
|
114
|
+
const work = self.pipe(
|
|
115
|
+
Effect.tapCause((cause) =>
|
|
116
|
+
Cause.hasDies(cause) ? Effect.logError(deferredFailureMessage, cause) : Effect.void,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
const fiber = yield* scope === undefined ? Effect.forkDetach(work) : Effect.forkIn(work, scope);
|
|
120
|
+
const promise: DeferredPromise<A> = new Promise<A>((resolve, reject) => {
|
|
121
|
+
fiber.addObserver((exit) => {
|
|
122
|
+
if (Exit.isSuccess(exit)) return resolve(exit.value);
|
|
123
|
+
reject(
|
|
124
|
+
Cause.hasDies(exit.cause)
|
|
125
|
+
? deferredFailure()
|
|
126
|
+
: Option.getOrElse(Cause.findErrorOption(exit.cause), deferredFailure),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
// Marks the rejection handled, so a promise that React Router never reads,
|
|
131
|
+
// because the loader failed after it deferred, is not an unhandled rejection.
|
|
132
|
+
// `<Await>` still reads the rejection from `promise` itself.
|
|
133
|
+
promise.catch(() => undefined);
|
|
134
|
+
return promise;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Route errors in deferred work.
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/** A thrown `data(...)` as an `ErrorResponse`, with React Router's defaults. */
|
|
142
|
+
const toErrorResponse = (thrown: DataWithResponseInit<unknown>): ErrorResponse =>
|
|
143
|
+
new ErrorResponseImpl(
|
|
144
|
+
thrown.init?.status ?? 500,
|
|
145
|
+
thrown.init?.statusText ?? "Internal Server Error",
|
|
146
|
+
thrown.data,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A library route error raised in deferred work: `Respond.early` resolves the
|
|
151
|
+
* promise with its value, and `Respond.throw` rejects it with an
|
|
152
|
+
* `ErrorResponse`. A redirect cannot happen after the response started, so it
|
|
153
|
+
* is a defect.
|
|
154
|
+
*/
|
|
155
|
+
export const deferRouteError = (e: AnyRouteError): Effect.Effect<unknown, ErrorResponse> => {
|
|
156
|
+
if (e instanceof ReturnableDataError) return Effect.succeed(e.data);
|
|
157
|
+
if (e instanceof ThrowableDataError) return Effect.fail(toErrorResponse(data(e.data, e.init)));
|
|
158
|
+
return Effect.die(e);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A handler's `Effect.fail(response)` in deferred work, as an `ErrorResponse`:
|
|
163
|
+
* a `data(...)` as React Router converts a thrown one, and a `Response` with
|
|
164
|
+
* its body read as React Router reads it, JSON or text. A redirect is a defect.
|
|
165
|
+
*/
|
|
166
|
+
export const deferFailureResponse = (
|
|
167
|
+
failure: FailureResponse,
|
|
168
|
+
): Effect.Effect<never, ErrorResponse> => {
|
|
169
|
+
if (!(failure instanceof Response)) return Effect.fail(toErrorResponse(failure));
|
|
170
|
+
if (failure.status >= 300 && failure.status < 400) return Effect.die(failure);
|
|
171
|
+
const isJson = /\bapplication\/json\b/.test(failure.headers.get("Content-Type") ?? "");
|
|
172
|
+
return Effect.promise(() =>
|
|
173
|
+
isJson && failure.body !== null ? failure.json() : failure.text(),
|
|
174
|
+
).pipe(
|
|
175
|
+
Effect.flatMap((body) =>
|
|
176
|
+
Effect.fail(new ErrorResponseImpl(failure.status, failure.statusText, body)),
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
};
|