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.
- package/README.md +724 -171
- package/dist/index.d.mts +2 -342
- package/dist/index.mjs +122 -33
- package/dist/rolldown-runtime-D8jQpDG7.mjs +12 -0
- package/dist/route-data-CWBxnNAA.d.mts +549 -0
- package/dist/server.d.mts +284 -0
- package/dist/server.mjs +483 -0
- package/dist/vite.d.mts +48 -0
- package/dist/vite.mjs +80 -0
- package/package.json +25 -9
- package/src/action-meta.ts +58 -0
- package/src/assets.ts +212 -0
- package/src/check.ts +213 -0
- package/src/errors.ts +3 -3
- package/src/factory.ts +301 -91
- package/src/index.ts +22 -1
- package/src/route-data.ts +151 -0
- package/src/server-entry.ts +11 -0
- package/src/server.ts +659 -0
- package/src/vite-entry.ts +2 -0
- package/src/vite.ts +169 -0
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.2.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",
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"type": "module",
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./dist/index.mjs",
|
|
33
|
+
"./server": "./dist/server.mjs",
|
|
34
|
+
"./vite": "./dist/vite.mjs",
|
|
33
35
|
"./package.json": "./package.json"
|
|
34
36
|
},
|
|
35
37
|
"publishConfig": {
|
|
@@ -38,27 +40,37 @@
|
|
|
38
40
|
"devDependencies": {
|
|
39
41
|
"@changesets/changelog-github": "^1.0.1",
|
|
40
42
|
"@changesets/cli": "^3.0.3",
|
|
43
|
+
"@effect/platform-node": "4.1.2",
|
|
44
|
+
"@effect/tsgo": "0.67.5",
|
|
45
|
+
"@react-router/node": "8.4.0",
|
|
41
46
|
"@types/node": "^25.6.2",
|
|
42
47
|
"@types/react": "^19.2.17",
|
|
43
48
|
"@types/react-dom": "^19.2.3",
|
|
44
49
|
"@vitejs/plugin-react": "^6.0.4",
|
|
45
|
-
"@vitest/browser": "5.0
|
|
46
|
-
"@vitest/browser-playwright": "5.0
|
|
47
|
-
"effect": "4.
|
|
48
|
-
"
|
|
50
|
+
"@vitest/browser": "5.1.0",
|
|
51
|
+
"@vitest/browser-playwright": "5.1.0",
|
|
52
|
+
"effect": "4.1.2",
|
|
53
|
+
"miniflare": "5.20261104.0-beta",
|
|
54
|
+
"playwright": "1.64.1",
|
|
49
55
|
"react": "^19.2.8",
|
|
50
56
|
"react-dom": "^19.2.8",
|
|
51
57
|
"react-router": "^8.3.0",
|
|
52
58
|
"typescript": "^7.0.2",
|
|
53
59
|
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
|
54
|
-
"vite-plus": "^1.
|
|
55
|
-
"vitest": "5.0.
|
|
60
|
+
"vite-plus": "^1.5.0",
|
|
61
|
+
"vitest": "5.0.5",
|
|
56
62
|
"vitest-browser-react": "^2.2.0"
|
|
57
63
|
},
|
|
58
64
|
"peerDependencies": {
|
|
59
|
-
"effect": "^4.
|
|
65
|
+
"@effect/platform-node": "^4.1.0",
|
|
66
|
+
"effect": "^4.1.0",
|
|
60
67
|
"react-router": "^7.16.0 || ^8.3.0"
|
|
61
68
|
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"@effect/platform-node": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
62
74
|
"devEngines": {
|
|
63
75
|
"packageManager": {
|
|
64
76
|
"name": "pnpm",
|
|
@@ -66,6 +78,9 @@
|
|
|
66
78
|
"onFail": "warn"
|
|
67
79
|
}
|
|
68
80
|
},
|
|
81
|
+
"engines": {
|
|
82
|
+
"node": ">=22"
|
|
83
|
+
},
|
|
69
84
|
"scripts": {
|
|
70
85
|
"build": "vp pack",
|
|
71
86
|
"dev": "vp pack --watch",
|
|
@@ -73,7 +88,8 @@
|
|
|
73
88
|
"test:unit": "vp test run --project unit",
|
|
74
89
|
"test:browser": "vp test run --project browser",
|
|
75
90
|
"test:browser:install": "vp exec playwright install chromium",
|
|
76
|
-
"check": "vp check",
|
|
91
|
+
"check": "effect-tsgo vp check",
|
|
92
|
+
"check:consumer": "tsc -p test/consumer",
|
|
77
93
|
"changeset": "changeset",
|
|
78
94
|
"changeset:version": "changeset version",
|
|
79
95
|
"changeset:release": "vp run build && changeset publish"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Predicate } from "effect";
|
|
2
|
+
import { data, type UNSAFE_DataWithResponseInit as DataWithResponseInit } from "react-router";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Action metadata: request-scoped facts attached to every action's data.
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** The key that holds the metadata in action data. */
|
|
9
|
+
export const actionMetaKey = "__meta";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adds optional metadata to a record. Only records get metadata, because a
|
|
13
|
+
* structural type cannot tell a plain object from a class instance, and other
|
|
14
|
+
* values (strings, arrays, `Response`s) have nowhere to put it.
|
|
15
|
+
*/
|
|
16
|
+
type RecordWithMeta<T, Meta> =
|
|
17
|
+
T extends Record<string, unknown>
|
|
18
|
+
? Omit<T, typeof actionMetaKey> & { [actionMetaKey]?: Meta }
|
|
19
|
+
: T;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The type of action data after metadata is attached. A `data(...)` wrapper
|
|
23
|
+
* gets metadata on its body. With no configured metadata (`Meta` is `never`)
|
|
24
|
+
* the data is unchanged.
|
|
25
|
+
*/
|
|
26
|
+
export type WithActionMeta<T, Meta> = [Meta] extends [never]
|
|
27
|
+
? T
|
|
28
|
+
: T extends DataWithResponseInit<infer Body>
|
|
29
|
+
? DataWithResponseInit<RecordWithMeta<Body, Meta>>
|
|
30
|
+
: RecordWithMeta<T, Meta>;
|
|
31
|
+
|
|
32
|
+
// React Router exports the type of the `data(...)` wrapper, but not its class.
|
|
33
|
+
const DataWithResponseInitClass = data(undefined).constructor;
|
|
34
|
+
|
|
35
|
+
/** True for a `data(...)` wrapper, never for a business field that looks like one. */
|
|
36
|
+
const isDataWithResponseInit = (value: unknown): value is DataWithResponseInit<unknown> =>
|
|
37
|
+
value instanceof DataWithResponseInitClass;
|
|
38
|
+
|
|
39
|
+
/** True for a plain object, which can be copied without losing behavior. */
|
|
40
|
+
const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
|
|
41
|
+
if (!Predicate.isObject(value) || Array.isArray(value)) return false;
|
|
42
|
+
const prototype: object | null = Object.getPrototypeOf(value);
|
|
43
|
+
return prototype === Object.prototype || prototype === null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Attaches `meta` to the action result. A plain record gets a copy with the
|
|
48
|
+
* metadata key; a `data(...)` wrapper gets it on its body and keeps its init.
|
|
49
|
+
* Any other value is returned unchanged.
|
|
50
|
+
*/
|
|
51
|
+
export const attachActionMeta = <T>(value: T, meta: unknown): T => {
|
|
52
|
+
if (isDataWithResponseInit(value)) {
|
|
53
|
+
if (!isPlainRecord(value.data)) return value;
|
|
54
|
+
// The copy has the same shape as `value` plus the metadata key.
|
|
55
|
+
return data({ ...value.data, [actionMetaKey]: meta }, value.init ?? undefined) as T;
|
|
56
|
+
}
|
|
57
|
+
return isPlainRecord(value) ? ({ ...value, [actionMetaKey]: meta } as T) : value;
|
|
58
|
+
};
|
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
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { Register as RouterRegister } from "react-router";
|
|
3
|
+
|
|
4
|
+
import type { ReturnableDataError } from "./errors.ts";
|
|
5
|
+
import type { FactoryTypes, FailureResponse, IsAny, LibraryHandled, NameError } from "./factory.ts";
|
|
6
|
+
import type { RegisteredMeta, RegisteredRecover } from "./route-data.ts";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// The route check: every Effect a route module exports must be fully handled.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/** The route modules React Router's typegen registers, keyed by route ID. */
|
|
13
|
+
type RegisteredRouteModules = RouterRegister extends { readonly routeModules: infer Modules }
|
|
14
|
+
? Modules
|
|
15
|
+
: {};
|
|
16
|
+
|
|
17
|
+
/** What an effect middleware may succeed with: React Router's response, or nothing. */
|
|
18
|
+
type MiddlewareResult = Response | void | undefined;
|
|
19
|
+
|
|
20
|
+
/** What a failure says to do, under its `react-router-effect` key. */
|
|
21
|
+
type FailureMessage =
|
|
22
|
+
"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.";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One export that fails the check. `unhandledErrors` lists each error the
|
|
26
|
+
* effect can fail with that the factory does not handle, by its `_tag`.
|
|
27
|
+
* `missingServices` lists each service that neither the runtime nor the
|
|
28
|
+
* request context provides. `invalidResults` lists what a middleware succeeds
|
|
29
|
+
* with that is not a `Response` or nothing.
|
|
30
|
+
*
|
|
31
|
+
* It is an inline object type, not an interface, so the compiler prints its
|
|
32
|
+
* fields in the error rather than an alias name.
|
|
33
|
+
*
|
|
34
|
+
* @stability unstable
|
|
35
|
+
*/
|
|
36
|
+
export type RouteEffectFailure<Route, Export, Unhandled, Missing, Invalid> = {
|
|
37
|
+
readonly "react-router-effect": FailureMessage;
|
|
38
|
+
readonly route: Route;
|
|
39
|
+
readonly export: Export;
|
|
40
|
+
readonly unhandledErrors: Unhandled;
|
|
41
|
+
readonly missingServices: Missing;
|
|
42
|
+
readonly invalidResults: Invalid;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The failure of one route function, or `never` when it does not return an
|
|
47
|
+
* Effect or when its Effect is fully handled. An Effect whose error or
|
|
48
|
+
* service type is `any` comes from a mistake TypeScript already reports, so it
|
|
49
|
+
* is skipped rather than reported a second time.
|
|
50
|
+
*/
|
|
51
|
+
type FunctionFailure<Route, Export, F, Handled, Provided, Result> = F extends (
|
|
52
|
+
...args: never[]
|
|
53
|
+
) => Effect.Effect<infer A, infer E, infer R>
|
|
54
|
+
? true extends IsAny<E> | IsAny<R>
|
|
55
|
+
? never
|
|
56
|
+
: [Exclude<E, Handled>, Exclude<R, Provided>, Exclude<A, Result>] extends [never, never, never]
|
|
57
|
+
? never
|
|
58
|
+
: {
|
|
59
|
+
// Inline, not `RouteEffectFailure`, so the compiler prints the fields.
|
|
60
|
+
readonly "react-router-effect": FailureMessage;
|
|
61
|
+
readonly route: Route;
|
|
62
|
+
readonly export: Export;
|
|
63
|
+
readonly unhandledErrors: NameError<Exclude<E, Handled>>;
|
|
64
|
+
readonly missingServices: Exclude<R, Provided>;
|
|
65
|
+
readonly invalidResults: Exclude<A, Result>;
|
|
66
|
+
}
|
|
67
|
+
: never;
|
|
68
|
+
|
|
69
|
+
/** The failures of one route module's loader, action and middleware. */
|
|
70
|
+
type ModuleFailures<Route, Module, Handled, Provided> =
|
|
71
|
+
| (Module extends { readonly loader: infer F }
|
|
72
|
+
? FunctionFailure<Route, "loader", F, Handled, Provided, unknown>
|
|
73
|
+
: never)
|
|
74
|
+
| (Module extends { readonly action: infer F }
|
|
75
|
+
? FunctionFailure<Route, "action", F, Handled, Provided, unknown>
|
|
76
|
+
: never)
|
|
77
|
+
| (Module extends { readonly middleware: ReadonlyArray<infer F> }
|
|
78
|
+
? FunctionFailure<
|
|
79
|
+
Route,
|
|
80
|
+
"middleware",
|
|
81
|
+
F,
|
|
82
|
+
Handled | FailureResponse,
|
|
83
|
+
Provided,
|
|
84
|
+
MiddlewareResult
|
|
85
|
+
>
|
|
86
|
+
: never);
|
|
87
|
+
|
|
88
|
+
/** The failures of every registered route module. */
|
|
89
|
+
type AllFailures<Modules, Handled, Provided> = {
|
|
90
|
+
[Route in keyof Modules]: ModuleFailures<Route, Modules[Route], Handled, Provided>;
|
|
91
|
+
}[keyof Modules];
|
|
92
|
+
|
|
93
|
+
/** The types of a route factory. */
|
|
94
|
+
type TypesOf<Factory> = Factory extends { readonly "~types": infer Types } ? Types : never;
|
|
95
|
+
|
|
96
|
+
/** What one error handler's return recovers into, or `never` when it throws. */
|
|
97
|
+
type HandlerRecovered<Return> =
|
|
98
|
+
Return extends ReturnableDataError<infer Body>
|
|
99
|
+
? Body
|
|
100
|
+
: Return extends Effect.Effect<infer Success, infer _E, infer _R>
|
|
101
|
+
? Success
|
|
102
|
+
: never;
|
|
103
|
+
|
|
104
|
+
/** The factory's recover map: each error tag whose handler recovers, and its data. */
|
|
105
|
+
type FactoryRecover<Returns> = {
|
|
106
|
+
[
|
|
107
|
+
Tag in keyof Returns as [HandlerRecovered<Returns[Tag]>] extends [never] ? never : Tag
|
|
108
|
+
]: HandlerRecovered<Returns[Tag]>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** True when each type is assignable to the other. */
|
|
112
|
+
type SameType<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A disagreement between the `Register` of `react-router-effect` and the
|
|
116
|
+
* factory, which would type plain Effect routes' data wrongly.
|
|
117
|
+
*
|
|
118
|
+
* @stability unstable
|
|
119
|
+
*/
|
|
120
|
+
export interface RegisterMismatch<Key, Factory, Registered> {
|
|
121
|
+
readonly register: Key;
|
|
122
|
+
readonly factory: Factory;
|
|
123
|
+
readonly registered: Registered;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The registration mismatches of a factory's types. */
|
|
127
|
+
type RegisterMismatches<Returns, Meta> =
|
|
128
|
+
| (SameType<FactoryRecover<Returns>, RegisteredRecover> extends true
|
|
129
|
+
? never
|
|
130
|
+
: RegisterMismatch<"recover", FactoryRecover<Returns>, RegisteredRecover>)
|
|
131
|
+
| (SameType<Meta, RegisteredMeta> extends true
|
|
132
|
+
? never
|
|
133
|
+
: RegisterMismatch<"actionMeta", Meta, RegisteredMeta>);
|
|
134
|
+
|
|
135
|
+
/** Every failure: the routes', then the registration's. */
|
|
136
|
+
type Failures<Modules, Types> =
|
|
137
|
+
Types extends FactoryTypes<infer DomainError, infer Returns, infer Services, infer Meta>
|
|
138
|
+
?
|
|
139
|
+
| AllFailures<Modules, LibraryHandled<DomainError>, Services>
|
|
140
|
+
| RegisterMismatches<Returns, Meta>
|
|
141
|
+
: never;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The failures of every Effect route function across the route modules React
|
|
145
|
+
* Router's typegen registers, and the registration mismatches, as objects.
|
|
146
|
+
* `never` when everything is handled. `RouteEffectCheck` reports them.
|
|
147
|
+
*
|
|
148
|
+
* @stability unstable
|
|
149
|
+
*/
|
|
150
|
+
export type RouteEffectFailures<Factory, Modules = RegisteredRouteModules> = Failures<
|
|
151
|
+
Modules,
|
|
152
|
+
TypesOf<Factory>
|
|
153
|
+
>;
|
|
154
|
+
|
|
155
|
+
/** A printable name for an error tag or another unhandled error type. */
|
|
156
|
+
type ErrorName<Name> = Name extends string ? Name : "an untagged error";
|
|
157
|
+
|
|
158
|
+
/** A printable name for a service: its key, such as `"app/Database"`. */
|
|
159
|
+
type ServiceName<Service> = Service extends { readonly key: infer Key extends string }
|
|
160
|
+
? Key
|
|
161
|
+
: "a service without a string key";
|
|
162
|
+
|
|
163
|
+
/** The diagnostic lines of one failure, one per error, service or result. */
|
|
164
|
+
type FailureLines<Failure> = Failure extends {
|
|
165
|
+
readonly route: infer Route extends string | number | symbol;
|
|
166
|
+
readonly export: infer Export extends string;
|
|
167
|
+
readonly unhandledErrors: infer Unhandled;
|
|
168
|
+
readonly missingServices: infer Missing;
|
|
169
|
+
readonly invalidResults: infer Invalid;
|
|
170
|
+
}
|
|
171
|
+
?
|
|
172
|
+
| (Unhandled extends unknown
|
|
173
|
+
? `${Route & string}#${Export} must handle ${ErrorName<Unhandled>}`
|
|
174
|
+
: never)
|
|
175
|
+
| (Missing extends unknown
|
|
176
|
+
? `${Route & string}#${Export} needs a provider for ${ServiceName<Missing>}`
|
|
177
|
+
: never)
|
|
178
|
+
| ([Invalid] extends [never]
|
|
179
|
+
? never
|
|
180
|
+
: `${Route & string}#${Export} must succeed with a Response or nothing`)
|
|
181
|
+
: Failure extends { readonly register: infer Key extends string }
|
|
182
|
+
? `the Register of react-router-effect: ${Key} does not match the route factory`
|
|
183
|
+
: never;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Checks every loader, action and middleware that returns an Effect, across
|
|
187
|
+
* the route modules React Router's typegen registers. Each failure is a
|
|
188
|
+
* required property whose name says what to fix, so assigning `{}` fails to
|
|
189
|
+
* compile with a list such as:
|
|
190
|
+
*
|
|
191
|
+
* ```text
|
|
192
|
+
* Type '{}' is missing the following properties from type ...:
|
|
193
|
+
* "routes/billing#loader must handle PlaidApiError",
|
|
194
|
+
* "routes/admin#middleware needs a provider for app/Session"
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
197
|
+
* ```ts
|
|
198
|
+
* import type { RouteEffectCheck } from "react-router-effect/server";
|
|
199
|
+
*
|
|
200
|
+
* export const routeEffects: RouteEffectCheck<typeof routeFactory> = {};
|
|
201
|
+
* ```
|
|
202
|
+
*
|
|
203
|
+
* An Effect may fail with the factory's domain errors, the library's `Respond`
|
|
204
|
+
* errors, and `HttpServerRespondable` errors, and require the services of the
|
|
205
|
+
* factory's runtime and request context. A middleware may also fail with what
|
|
206
|
+
* its `next()` fails with. The check also fails when the `Register` of
|
|
207
|
+
* `react-router-effect` disagrees with the factory.
|
|
208
|
+
*
|
|
209
|
+
* @stability unstable
|
|
210
|
+
*/
|
|
211
|
+
export type RouteEffectCheck<Factory, Modules = RegisteredRouteModules> = {
|
|
212
|
+
readonly [Line in FailureLines<RouteEffectFailures<Factory, Modules>>]: Line;
|
|
213
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Data } from "effect";
|
|
2
2
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
-
// Standardized errors
|
|
4
|
+
// Standardized errors - the library translates these out of the box.
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
7
|
/** Recoverable: short-circuits the loader but returns `data(...)` the component reads. */
|
|
@@ -31,7 +31,7 @@ export type AnyRouteError =
|
|
|
31
31
|
/**
|
|
32
32
|
* The library's base helpers for raising route errors (`early` / `throw` /
|
|
33
33
|
* `redirect`). This is internal: it isn't exported from the package. The factory
|
|
34
|
-
*
|
|
34
|
+
* passes it to your config builder and returns it (merged with any `respond`
|
|
35
35
|
* extensions) so your app imports a single `Respond`:
|
|
36
36
|
*
|
|
37
37
|
* ```ts
|
|
@@ -44,7 +44,7 @@ export type AnyRouteError =
|
|
|
44
44
|
* ```
|
|
45
45
|
*/
|
|
46
46
|
export const Respond = {
|
|
47
|
-
/** Recover: short-circuit and
|
|
47
|
+
/** Recover: short-circuit and pass `value` to the component as loader/action data. */
|
|
48
48
|
early: <D>(value: D, init?: number | ResponseInit) =>
|
|
49
49
|
new ReturnableDataError({ data: value, init }),
|
|
50
50
|
/** Throw: short-circuit to the error boundary with `data(value, init)`. */
|