rwsdk 1.0.0-beta.43 → 1.0.0-beta.44
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/dist/runtime/client/client.js +1 -1
- package/dist/runtime/entries/no-react-server-ssr-bridge.js +1 -1
- package/dist/runtime/entries/no-react-server.js +3 -1
- package/dist/runtime/entries/react-server-only.js +1 -1
- package/dist/runtime/entries/worker.d.ts +1 -0
- package/dist/runtime/entries/worker.js +1 -0
- package/dist/runtime/lib/links.js +6 -6
- package/dist/runtime/lib/router.d.ts +19 -17
- package/dist/runtime/lib/router.js +343 -108
- package/dist/runtime/lib/router.test.js +343 -1
- package/dist/runtime/requestInfo/types.d.ts +1 -0
- package/dist/runtime/requestInfo/utils.js +1 -0
- package/dist/runtime/requestInfo/worker.js +2 -1
- package/dist/runtime/worker.js +6 -1
- package/dist/vite/runDirectivesScan.mjs +3 -1
- package/package.json +2 -1
|
@@ -179,7 +179,7 @@ export const initClient = async ({ transport = fetchTransport, hydrateRootOption
|
|
|
179
179
|
};
|
|
180
180
|
const rootEl = document.getElementById("hydrate-root");
|
|
181
181
|
if (!rootEl) {
|
|
182
|
-
throw new Error('
|
|
182
|
+
throw new Error('RedwoodSDK: No element with id "hydrate-root" found in the document. This element is required for hydration. Ensure your Document component contains a <div id="hydrate-root">{children}</div>.');
|
|
183
183
|
}
|
|
184
184
|
let rscPayload;
|
|
185
185
|
// context(justinvdm, 18 Jun 2025): We inject the RSC payload
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
throw new Error("
|
|
2
|
+
throw new Error("RedwoodSDK: SSR bridge was resolved with 'react-server' condition. This is a bug - the SSR bridge should be intercepted by the esbuild plugin before reaching package.json exports. Please report this issue at https://github.com/redwoodjs/sdk/issues.");
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
throw new Error("
|
|
2
|
+
throw new Error("RedwoodSDK: A client-only module was incorrectly resolved with the 'react-server' condition.\n\n" +
|
|
3
|
+
"This error occurs when modules like 'rwsdk/client', 'rwsdk/__ssr', or 'rwsdk/__ssr_bridge' are being imported in a React Server Components context.\n\n" +
|
|
4
|
+
"For detailed troubleshooting steps, see: https://docs.rwsdk.com/guides/troubleshooting#react-server-components-configuration-errors");
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
throw new Error("
|
|
2
|
+
throw new Error("RedwoodSDK: 'react-server' import condition needs to be used in this environment. This code should only run in React Server Components (RSC) context. Check that you're not importing server-only code in client components.");
|
|
@@ -19,13 +19,13 @@ export function defineLinks(routes) {
|
|
|
19
19
|
// Original implementation for route arrays
|
|
20
20
|
routes.forEach((route) => {
|
|
21
21
|
if (typeof route !== "string") {
|
|
22
|
-
throw new Error(`Invalid route: ${route}. Routes must be
|
|
22
|
+
throw new Error(`RedwoodSDK: Invalid route: ${route}. Routes must be string literals. Ensure you're passing an array of route paths.`);
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
const link = createLinkFunction();
|
|
26
26
|
return ((path, params) => {
|
|
27
27
|
if (!routes.includes(path)) {
|
|
28
|
-
throw new Error(`Invalid route: ${path}
|
|
28
|
+
throw new Error(`RedwoodSDK: Invalid route: ${path}. This route is not included in the routes array passed to defineLinks(). Check for typos or ensure the route is defined in your router.`);
|
|
29
29
|
}
|
|
30
30
|
return link(path, params);
|
|
31
31
|
});
|
|
@@ -36,7 +36,7 @@ function createLinkFunction() {
|
|
|
36
36
|
const expectsParams = hasRouteParameters(path);
|
|
37
37
|
if (!params || Object.keys(params).length === 0) {
|
|
38
38
|
if (expectsParams) {
|
|
39
|
-
throw new Error(`Route ${path} requires an object of parameters
|
|
39
|
+
throw new Error(`RedwoodSDK: Route ${path} requires an object of parameters (e.g., link("${path}", { id: "123" })).`);
|
|
40
40
|
}
|
|
41
41
|
return path;
|
|
42
42
|
}
|
|
@@ -62,7 +62,7 @@ function interpolate(template, params) {
|
|
|
62
62
|
const name = match[1];
|
|
63
63
|
const value = params[name];
|
|
64
64
|
if (value === undefined) {
|
|
65
|
-
throw new Error(`Missing parameter "${name}" for route ${template}
|
|
65
|
+
throw new Error(`RedwoodSDK: Missing parameter "${name}" for route ${template}. Ensure you're providing all required parameters in the params object.`);
|
|
66
66
|
}
|
|
67
67
|
result += encodeURIComponent(value);
|
|
68
68
|
consumed.add(name);
|
|
@@ -71,7 +71,7 @@ function interpolate(template, params) {
|
|
|
71
71
|
const key = `$${wildcardIndex}`;
|
|
72
72
|
const value = params[key];
|
|
73
73
|
if (value === undefined) {
|
|
74
|
-
throw new Error(`Missing parameter "${key}" for route ${template}
|
|
74
|
+
throw new Error(`RedwoodSDK: Missing parameter "${key}" for route ${template}. Wildcard routes use $0, $1, etc. as parameter keys.`);
|
|
75
75
|
}
|
|
76
76
|
result += encodeWildcardValue(value);
|
|
77
77
|
consumed.add(key);
|
|
@@ -82,7 +82,7 @@ function interpolate(template, params) {
|
|
|
82
82
|
result += template.slice(lastIndex);
|
|
83
83
|
for (const key of Object.keys(params)) {
|
|
84
84
|
if (!consumed.has(key)) {
|
|
85
|
-
throw new Error(`Parameter "${key}" is not used by route ${template}
|
|
85
|
+
throw new Error(`RedwoodSDK: Parameter "${key}" is not used by route ${template}. Check your params object for typos or remove unused parameters.`);
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
TOKEN_REGEX.lastIndex = 0;
|
|
@@ -6,6 +6,10 @@ type BivariantRouteHandler<T extends RequestInfo, R> = {
|
|
|
6
6
|
bivarianceHack(requestInfo: T): R;
|
|
7
7
|
}["bivarianceHack"];
|
|
8
8
|
export type RouteMiddleware<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<React.JSX.Element | Response | void>>;
|
|
9
|
+
export type ExceptHandler<T extends RequestInfo = RequestInfo> = {
|
|
10
|
+
__rwExcept: true;
|
|
11
|
+
handler: (error: unknown, requestInfo: T) => MaybePromise<React.JSX.Element | Response | void>;
|
|
12
|
+
};
|
|
9
13
|
type RouteFunction<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<Response>>;
|
|
10
14
|
type RouteComponent<T extends RequestInfo = RequestInfo> = BivariantRouteHandler<T, MaybePromise<React.JSX.Element | Response | void>>;
|
|
11
15
|
type RouteHandler<T extends RequestInfo = RequestInfo> = RouteFunction<T> | RouteComponent<T> | readonly [...RouteMiddleware<T>[], RouteFunction<T> | RouteComponent<T>];
|
|
@@ -22,7 +26,7 @@ export type MethodHandlers<T extends RequestInfo = RequestInfo> = {
|
|
|
22
26
|
[method: string]: RouteHandler<T>;
|
|
23
27
|
};
|
|
24
28
|
};
|
|
25
|
-
export type Route<T extends RequestInfo = RequestInfo> = RouteMiddleware<T> | RouteDefinition<string, T> | readonly Route<T>[];
|
|
29
|
+
export type Route<T extends RequestInfo = RequestInfo> = RouteMiddleware<T> | RouteDefinition<string, T> | ExceptHandler<T> | readonly Route<T>[];
|
|
26
30
|
type NormalizedRouteDefinition<T extends RequestInfo = RequestInfo> = {
|
|
27
31
|
path: string;
|
|
28
32
|
handler: RouteHandler<T> | MethodHandlers<T>;
|
|
@@ -36,7 +40,7 @@ type TrimLeadingSlash<S extends string> = S extends `/${infer Rest}` ? TrimLeadi
|
|
|
36
40
|
type NormalizePrefix<Prefix extends string> = TrimTrailingSlash<TrimLeadingSlash<Prefix>> extends "" ? "" : `/${TrimTrailingSlash<TrimLeadingSlash<Prefix>>}`;
|
|
37
41
|
type NormalizePath<Path extends string> = TrimTrailingSlash<Path> extends "/" ? "/" : `/${TrimTrailingSlash<TrimLeadingSlash<Path>>}`;
|
|
38
42
|
type JoinPaths<Prefix extends string, Path extends string> = NormalizePrefix<Prefix> extends "" ? NormalizePath<Path> : Path extends "/" ? NormalizePrefix<Prefix> : `${NormalizePrefix<Prefix>}${NormalizePath<Path>}`;
|
|
39
|
-
type PrefixedRouteValue<Prefix extends string, Value> = Value extends RouteDefinition<infer Path, infer Req> ? RouteDefinition<JoinPaths<Prefix, Path>, Req> : Value extends readonly Route<any>[] ? PrefixedRouteArray<Prefix, Value> : Value;
|
|
43
|
+
type PrefixedRouteValue<Prefix extends string, Value> = Value extends RouteDefinition<infer Path, infer Req> ? RouteDefinition<JoinPaths<Prefix, Path>, Req> : Value extends ExceptHandler<any> ? Value : Value extends readonly Route<any>[] ? PrefixedRouteArray<Prefix, Value> : Value;
|
|
40
44
|
type PrefixedRouteArray<Prefix extends string, Routes extends readonly Route<any>[]> = Routes extends readonly [] ? [] : Routes extends readonly [infer Head, ...infer Tail] ? readonly [
|
|
41
45
|
PrefixedRouteValue<Prefix, Head>,
|
|
42
46
|
...PrefixedRouteArray<Prefix, Tail extends readonly Route<any>[] ? Tail : []>
|
|
@@ -104,24 +108,22 @@ export declare function route<Path extends string, T extends RequestInfo = Reque
|
|
|
104
108
|
*/
|
|
105
109
|
export declare function index<T extends RequestInfo = RequestInfo>(handler: RouteHandler<T>): RouteDefinition<"/", T>;
|
|
106
110
|
/**
|
|
107
|
-
*
|
|
111
|
+
* Defines an error handler that catches errors from routes, middleware, and RSC actions.
|
|
108
112
|
*
|
|
109
113
|
* @example
|
|
110
|
-
* //
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* ]
|
|
114
|
+
* // Global error handler
|
|
115
|
+
* except((error, requestInfo) => {
|
|
116
|
+
* console.error(error);
|
|
117
|
+
* return new Response("Internal Server Error", { status: 500 });
|
|
118
|
+
* })
|
|
116
119
|
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* ]),
|
|
123
|
-
* ])
|
|
120
|
+
* @example
|
|
121
|
+
* // Error handler that returns a React component
|
|
122
|
+
* except((error) => {
|
|
123
|
+
* return <ErrorPage error={error} />;
|
|
124
|
+
* })
|
|
124
125
|
*/
|
|
126
|
+
export declare function except<T extends RequestInfo = RequestInfo>(handler: (error: unknown, requestInfo: T) => MaybePromise<React.JSX.Element | Response | void>): ExceptHandler<T>;
|
|
125
127
|
export declare function prefix<Prefix extends string, T extends RequestInfo = RequestInfo, Routes extends readonly Route<T>[] = readonly Route<T>[]>(prefixPath: Prefix, routes: Routes): PrefixedRouteArray<Prefix, Routes>;
|
|
126
128
|
export declare const wrapHandlerToThrowResponses: <T extends RequestInfo = RequestInfo>(handler: RouteFunction<T> | RouteComponent<T>) => RouteHandler<T>;
|
|
127
129
|
/**
|
|
@@ -129,7 +131,7 @@ export declare const wrapHandlerToThrowResponses: <T extends RequestInfo = Reque
|
|
|
129
131
|
*
|
|
130
132
|
* @example
|
|
131
133
|
* // Define a layout component
|
|
132
|
-
* function BlogLayout({ children }: { children
|
|
134
|
+
* function BlogLayout({ children }: { children?: React.ReactNode }) {
|
|
133
135
|
* return (
|
|
134
136
|
* <div>
|
|
135
137
|
* <nav>Blog Navigation</nav>
|
|
@@ -1,55 +1,66 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { isValidElementType } from "react-is";
|
|
3
3
|
const METHOD_VERBS = ["delete", "get", "head", "patch", "post", "put"];
|
|
4
|
-
|
|
4
|
+
const pathCache = new Map();
|
|
5
|
+
function compilePath(routePath) {
|
|
6
|
+
const cached = pathCache.get(routePath);
|
|
7
|
+
if (cached)
|
|
8
|
+
return cached;
|
|
5
9
|
// Check for invalid pattern: multiple colons in a segment (e.g., /:param1:param2/)
|
|
6
10
|
if (routePath.includes(":")) {
|
|
7
11
|
const segments = routePath.split("/");
|
|
8
12
|
for (const segment of segments) {
|
|
9
13
|
if ((segment.match(/:/g) || []).length > 1) {
|
|
10
|
-
throw new Error(`Invalid route pattern: segment "${segment}" in "${routePath}" contains multiple colons.`);
|
|
14
|
+
throw new Error(`RedwoodSDK: Invalid route pattern: segment "${segment}" in "${routePath}" contains multiple colons. Each route parameter should use a single colon (e.g., ":id"). Check for accidental double colons ("::").`);
|
|
11
15
|
}
|
|
12
16
|
}
|
|
13
17
|
}
|
|
14
18
|
// Check for invalid pattern: double wildcard (e.g., /**/)
|
|
15
19
|
if (routePath.indexOf("**") !== -1) {
|
|
16
|
-
throw new Error(`Invalid route pattern: "${routePath}" contains "**". Use "*" for a single wildcard segment.`);
|
|
20
|
+
throw new Error(`RedwoodSDK: Invalid route pattern: "${routePath}" contains "**". Use "*" for a single wildcard segment. Double wildcards are not supported.`);
|
|
17
21
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (!matches) {
|
|
24
|
-
return null;
|
|
22
|
+
const isStatic = !routePath.includes(":") && !routePath.includes("*");
|
|
23
|
+
if (isStatic) {
|
|
24
|
+
const result = { isStatic: true, regex: null, paramMap: [] };
|
|
25
|
+
pathCache.set(routePath, result);
|
|
26
|
+
return result;
|
|
25
27
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
let currentMatchIndex = 1; // Regex matches are 1-indexed
|
|
29
|
-
// This regex finds either a named parameter token (e.g., ":id") or a wildcard star token ("*").
|
|
28
|
+
const paramMap = [];
|
|
29
|
+
let wildcardCounter = 0;
|
|
30
30
|
const tokenRegex = /:([a-zA-Z0-9_]+)|\*/g;
|
|
31
31
|
let matchToken;
|
|
32
|
-
let wildcardCounter = 0;
|
|
33
|
-
// Ensure regex starts from the beginning of the routePath for each call if it's stateful (it is with /g)
|
|
34
|
-
tokenRegex.lastIndex = 0;
|
|
35
32
|
while ((matchToken = tokenRegex.exec(routePath)) !== null) {
|
|
36
|
-
// Ensure we have a corresponding match from the regex execution
|
|
37
|
-
if (matches[currentMatchIndex] === undefined) {
|
|
38
|
-
// This case should ideally not be hit if routePath and pattern generation are correct
|
|
39
|
-
// and all parts of the regex matched.
|
|
40
|
-
// Consider logging a warning or throwing an error if critical.
|
|
41
|
-
break;
|
|
42
|
-
}
|
|
43
33
|
if (matchToken[1]) {
|
|
44
|
-
|
|
45
|
-
params[matchToken[1]] = matches[currentMatchIndex];
|
|
34
|
+
paramMap.push({ name: matchToken[1], isWildcard: false });
|
|
46
35
|
}
|
|
47
36
|
else {
|
|
48
|
-
|
|
49
|
-
params[`$${wildcardCounter}`] = matches[currentMatchIndex];
|
|
50
|
-
wildcardCounter++;
|
|
37
|
+
paramMap.push({ name: `$${wildcardCounter++}`, isWildcard: true });
|
|
51
38
|
}
|
|
52
|
-
|
|
39
|
+
}
|
|
40
|
+
const pattern = routePath
|
|
41
|
+
.replace(/:[a-zA-Z0-9_]+/g, "([^/]+)") // Convert :param to capture group
|
|
42
|
+
.replace(/\*/g, "(.*)"); // Convert * to wildcard capture group
|
|
43
|
+
const result = {
|
|
44
|
+
isStatic: false,
|
|
45
|
+
regex: new RegExp(`^${pattern}$`),
|
|
46
|
+
paramMap,
|
|
47
|
+
};
|
|
48
|
+
pathCache.set(routePath, result);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
export function matchPath(routePath, requestPath) {
|
|
52
|
+
const compiled = compilePath(routePath);
|
|
53
|
+
if (compiled.isStatic) {
|
|
54
|
+
return routePath === requestPath ? {} : null;
|
|
55
|
+
}
|
|
56
|
+
const matches = requestPath.match(compiled.regex);
|
|
57
|
+
if (!matches) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const params = {};
|
|
61
|
+
for (let i = 0; i < compiled.paramMap.length; i++) {
|
|
62
|
+
const param = compiled.paramMap[i];
|
|
63
|
+
params[param.name] = matches[i + 1];
|
|
53
64
|
}
|
|
54
65
|
return params;
|
|
55
66
|
}
|
|
@@ -58,7 +69,10 @@ function flattenRoutes(routes) {
|
|
|
58
69
|
if (Array.isArray(route)) {
|
|
59
70
|
return [...acc, ...flattenRoutes(route)];
|
|
60
71
|
}
|
|
61
|
-
return [
|
|
72
|
+
return [
|
|
73
|
+
...acc,
|
|
74
|
+
route,
|
|
75
|
+
];
|
|
62
76
|
}, []);
|
|
63
77
|
}
|
|
64
78
|
function isMethodHandlers(handler) {
|
|
@@ -95,15 +109,41 @@ function getHandlerForMethod(methodHandlers, method) {
|
|
|
95
109
|
}
|
|
96
110
|
export function defineRoutes(routes) {
|
|
97
111
|
const flattenedRoutes = flattenRoutes(routes);
|
|
112
|
+
const compiledRoutes = flattenedRoutes.map((route) => {
|
|
113
|
+
if (typeof route === "function") {
|
|
114
|
+
return { type: "middleware", handler: route };
|
|
115
|
+
}
|
|
116
|
+
if (typeof route === "object" &&
|
|
117
|
+
route !== null &&
|
|
118
|
+
"__rwExcept" in route &&
|
|
119
|
+
route.__rwExcept === true) {
|
|
120
|
+
return { type: "except", handler: route };
|
|
121
|
+
}
|
|
122
|
+
const routeDef = route;
|
|
123
|
+
const compiledPath = compilePath(routeDef.path);
|
|
124
|
+
return {
|
|
125
|
+
type: "definition",
|
|
126
|
+
path: routeDef.path,
|
|
127
|
+
handler: routeDef.handler,
|
|
128
|
+
layouts: routeDef.layouts,
|
|
129
|
+
isStatic: compiledPath.isStatic,
|
|
130
|
+
regex: compiledPath.regex ?? undefined,
|
|
131
|
+
paramNames: compiledPath.paramMap
|
|
132
|
+
.filter((p) => !p.isWildcard)
|
|
133
|
+
.map((p) => p.name),
|
|
134
|
+
wildcardCount: compiledPath.paramMap.filter((p) => p.isWildcard).length,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
98
137
|
return {
|
|
99
138
|
routes: flattenedRoutes,
|
|
100
139
|
async handle({ request, renderPage, getRequestInfo, onError, runWithRequestInfoOverrides, rscActionHandler, }) {
|
|
140
|
+
const requestInfo = getRequestInfo();
|
|
101
141
|
const url = new URL(request.url);
|
|
102
142
|
let path = url.pathname;
|
|
103
|
-
// Must end with a trailing slash.
|
|
104
143
|
if (path !== "/" && !path.endsWith("/")) {
|
|
105
144
|
path = path + "/";
|
|
106
145
|
}
|
|
146
|
+
requestInfo.path = path;
|
|
107
147
|
// --- Helpers ---
|
|
108
148
|
// (Hoisted for readability)
|
|
109
149
|
function parseHandlers(handler) {
|
|
@@ -117,7 +157,17 @@ export function defineRoutes(routes) {
|
|
|
117
157
|
}
|
|
118
158
|
function renderElement(element) {
|
|
119
159
|
const requestInfo = getRequestInfo();
|
|
160
|
+
// Try to preserve the component name from the element's type
|
|
161
|
+
const elementType = element.type;
|
|
162
|
+
const componentName = typeof elementType === "function" && elementType.name
|
|
163
|
+
? elementType.name
|
|
164
|
+
: "Element";
|
|
120
165
|
const Element = () => element;
|
|
166
|
+
// Set the name for better debugging
|
|
167
|
+
Object.defineProperty(Element, "name", {
|
|
168
|
+
value: componentName,
|
|
169
|
+
configurable: true,
|
|
170
|
+
});
|
|
121
171
|
return renderPage(requestInfo, Element, onError);
|
|
122
172
|
}
|
|
123
173
|
async function handleMiddlewareResult(result) {
|
|
@@ -129,95 +179,193 @@ export function defineRoutes(routes) {
|
|
|
129
179
|
}
|
|
130
180
|
return undefined;
|
|
131
181
|
}
|
|
182
|
+
function isExceptHandler(route) {
|
|
183
|
+
return route.type === "except";
|
|
184
|
+
}
|
|
185
|
+
async function executeExceptHandlers(error, startIndex) {
|
|
186
|
+
// Search backwards from startIndex to find the most recent except handler
|
|
187
|
+
for (let i = startIndex; i >= 0; i--) {
|
|
188
|
+
const route = compiledRoutes[i];
|
|
189
|
+
if (isExceptHandler(route)) {
|
|
190
|
+
try {
|
|
191
|
+
const result = await route.handler.handler(error, getRequestInfo());
|
|
192
|
+
const handled = await handleMiddlewareResult(result);
|
|
193
|
+
if (handled) {
|
|
194
|
+
return handled;
|
|
195
|
+
}
|
|
196
|
+
// If the handler didn't return a Response or JSX, continue to next handler (further back)
|
|
197
|
+
}
|
|
198
|
+
catch (nextError) {
|
|
199
|
+
// If the except handler itself throws, try the next one (further back)
|
|
200
|
+
return await executeExceptHandlers(nextError, i - 1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// No handler found, throw to top-level onError
|
|
205
|
+
onError(error);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
132
208
|
// --- Main flow ---
|
|
133
209
|
let firstRouteDefinitionEncountered = false;
|
|
134
210
|
let actionHandled = false;
|
|
135
211
|
const handleAction = async () => {
|
|
136
|
-
|
|
137
|
-
|
|
212
|
+
// Handle RSC actions once per request, based on the incoming URL.
|
|
213
|
+
if (!actionHandled) {
|
|
214
|
+
const url = new URL(request.url);
|
|
215
|
+
if (url.searchParams.has("__rsc_action_id")) {
|
|
216
|
+
requestInfo.rw.actionResult = await rscActionHandler(request);
|
|
217
|
+
}
|
|
138
218
|
actionHandled = true;
|
|
139
219
|
}
|
|
140
220
|
};
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
// This is a RouteDefinition.
|
|
152
|
-
// The first time we see one, we handle any RSC actions.
|
|
153
|
-
if (!firstRouteDefinitionEncountered) {
|
|
154
|
-
firstRouteDefinitionEncountered = true;
|
|
155
|
-
await handleAction();
|
|
156
|
-
}
|
|
157
|
-
const params = matchPath(route.path, path);
|
|
158
|
-
if (!params) {
|
|
159
|
-
continue; // Not a match, keep going.
|
|
160
|
-
}
|
|
161
|
-
// Resolve handler if method-based routing
|
|
162
|
-
let handler;
|
|
163
|
-
if (isMethodHandlers(route.handler)) {
|
|
164
|
-
const requestMethod = request.method;
|
|
165
|
-
// Handle OPTIONS request
|
|
166
|
-
if (requestMethod === "OPTIONS" &&
|
|
167
|
-
!route.handler.config?.disableOptions) {
|
|
168
|
-
return handleOptionsRequest(route.handler);
|
|
221
|
+
try {
|
|
222
|
+
let currentRouteIndex = 0;
|
|
223
|
+
for (const route of compiledRoutes) {
|
|
224
|
+
// Skip except handlers during normal execution
|
|
225
|
+
if (route.type === "except") {
|
|
226
|
+
currentRouteIndex++;
|
|
227
|
+
continue;
|
|
169
228
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
229
|
+
if (route.type === "middleware") {
|
|
230
|
+
// This is a global middleware.
|
|
231
|
+
try {
|
|
232
|
+
const result = await route.handler(getRequestInfo());
|
|
233
|
+
const handled = await handleMiddlewareResult(result);
|
|
234
|
+
if (handled) {
|
|
235
|
+
return handled; // Short-circuit
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return await executeExceptHandlers(error, currentRouteIndex);
|
|
176
240
|
}
|
|
177
|
-
|
|
241
|
+
currentRouteIndex++;
|
|
178
242
|
continue;
|
|
179
243
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const result = await mw(getRequestInfo());
|
|
190
|
-
const handled = await handleMiddlewareResult(result);
|
|
191
|
-
if (handled) {
|
|
192
|
-
return handled;
|
|
244
|
+
// This is a RouteDefinition (route.type === "definition").
|
|
245
|
+
// The first time we see one, we handle any RSC actions.
|
|
246
|
+
if (!firstRouteDefinitionEncountered) {
|
|
247
|
+
firstRouteDefinitionEncountered = true;
|
|
248
|
+
try {
|
|
249
|
+
await handleAction();
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
return await executeExceptHandlers(error, currentRouteIndex);
|
|
193
253
|
}
|
|
194
254
|
}
|
|
195
|
-
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (!isClientReference(componentHandler)) {
|
|
200
|
-
requestInfo.rw.pageRouteResolved = Promise.withResolvers();
|
|
255
|
+
let params = null;
|
|
256
|
+
if (route.isStatic) {
|
|
257
|
+
if (route.path === path) {
|
|
258
|
+
params = {};
|
|
201
259
|
}
|
|
202
|
-
return await renderPage(requestInfo, WrappedComponent, onError);
|
|
203
260
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
261
|
+
else if (route.regex) {
|
|
262
|
+
const matches = path.match(route.regex);
|
|
263
|
+
if (matches) {
|
|
264
|
+
params = {};
|
|
265
|
+
for (let i = 0; i < route.paramNames.length; i++) {
|
|
266
|
+
params[route.paramNames[i]] = matches[i + 1];
|
|
267
|
+
}
|
|
268
|
+
for (let i = 0; i < route.wildcardCount; i++) {
|
|
269
|
+
params[`$${i}`] = matches[route.paramNames.length + i + 1];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
209
272
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
273
|
+
if (!params) {
|
|
274
|
+
currentRouteIndex++;
|
|
275
|
+
continue; // Not a match, keep going.
|
|
276
|
+
}
|
|
277
|
+
// Resolve handler if method-based routing
|
|
278
|
+
let handler;
|
|
279
|
+
if (isMethodHandlers(route.handler)) {
|
|
280
|
+
const requestMethod = request.method;
|
|
281
|
+
// Handle OPTIONS request
|
|
282
|
+
if (requestMethod === "OPTIONS" &&
|
|
283
|
+
!route.handler.config?.disableOptions) {
|
|
284
|
+
return handleOptionsRequest(route.handler);
|
|
285
|
+
}
|
|
286
|
+
// Try to find handler for the request method
|
|
287
|
+
handler = getHandlerForMethod(route.handler, requestMethod);
|
|
288
|
+
if (!handler) {
|
|
289
|
+
// Method not supported for this route
|
|
290
|
+
if (!route.handler.config?.disable405) {
|
|
291
|
+
return handleMethodNotAllowed(route.handler);
|
|
292
|
+
}
|
|
293
|
+
// If 405 is disabled, continue to next route
|
|
294
|
+
currentRouteIndex++;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
handler = route.handler;
|
|
300
|
+
}
|
|
301
|
+
// Found a match: run route-specific middlewares, then the final component, then stop.
|
|
302
|
+
try {
|
|
303
|
+
return await runWithRequestInfoOverrides({ params }, async () => {
|
|
304
|
+
const { routeMiddlewares, componentHandler } = parseHandlers(handler);
|
|
305
|
+
// Route-specific middlewares
|
|
306
|
+
for (const mw of routeMiddlewares) {
|
|
307
|
+
const result = await mw(getRequestInfo());
|
|
308
|
+
const handled = await handleMiddlewareResult(result);
|
|
309
|
+
if (handled) {
|
|
310
|
+
return handled;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Final component/handler
|
|
314
|
+
if (isRouteComponent(componentHandler)) {
|
|
315
|
+
const requestInfo = getRequestInfo();
|
|
316
|
+
const WrappedComponent = wrapWithLayouts(wrapHandlerToThrowResponses(componentHandler), route.layouts || [], requestInfo);
|
|
317
|
+
if (!isClientReference(componentHandler)) {
|
|
318
|
+
requestInfo.rw.pageRouteResolved = Promise.withResolvers();
|
|
319
|
+
}
|
|
320
|
+
return await renderPage(requestInfo, WrappedComponent, onError);
|
|
321
|
+
}
|
|
322
|
+
// Handle non-component final handler (e.g., returns new Response)
|
|
323
|
+
const tailResult = await componentHandler(getRequestInfo());
|
|
324
|
+
const handledTail = await handleMiddlewareResult(tailResult);
|
|
325
|
+
if (handledTail) {
|
|
326
|
+
return handledTail;
|
|
327
|
+
}
|
|
328
|
+
const handlerName = typeof componentHandler === "function" &&
|
|
329
|
+
componentHandler.name
|
|
330
|
+
? componentHandler.name
|
|
331
|
+
: "anonymous";
|
|
332
|
+
const errorMessage = `Route handler did not return a Response or React element.
|
|
333
|
+
|
|
334
|
+
Route: ${route.path}
|
|
335
|
+
Matched path: ${path}
|
|
336
|
+
Method: ${request.method}
|
|
337
|
+
Handler: ${handlerName}
|
|
338
|
+
|
|
339
|
+
Route handlers must return one of:
|
|
340
|
+
- A Response object (e.g., \`new Response("OK")\`)
|
|
341
|
+
- A React element (e.g., \`<div>Hello</div>\`)
|
|
342
|
+
- \`void\` (if handled by middleware earlier in the chain)`;
|
|
343
|
+
return new Response(errorMessage, {
|
|
344
|
+
status: 500,
|
|
345
|
+
headers: { "Content-Type": "text/plain" },
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
return await executeExceptHandlers(error, currentRouteIndex);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// If we've gotten this far, no route was matched.
|
|
354
|
+
// We still need to handle a possible action if the app has no route definitions at all.
|
|
355
|
+
if (!firstRouteDefinitionEncountered) {
|
|
356
|
+
try {
|
|
357
|
+
await handleAction();
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
return await executeExceptHandlers(error, compiledRoutes.length - 1);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return new Response("Not Found", { status: 404 });
|
|
214
364
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
await handleAction();
|
|
365
|
+
catch (error) {
|
|
366
|
+
// Top-level catch for any unhandled errors
|
|
367
|
+
return await executeExceptHandlers(error, compiledRoutes.length - 1);
|
|
219
368
|
}
|
|
220
|
-
return new Response("Not Found", { status: 404 });
|
|
221
369
|
},
|
|
222
370
|
};
|
|
223
371
|
}
|
|
@@ -260,6 +408,9 @@ export function defineRoutes(routes) {
|
|
|
260
408
|
*/
|
|
261
409
|
export function route(path, handler) {
|
|
262
410
|
let normalizedPath = path;
|
|
411
|
+
if (!normalizedPath.startsWith("/")) {
|
|
412
|
+
normalizedPath = "/" + normalizedPath;
|
|
413
|
+
}
|
|
263
414
|
if (!normalizedPath.endsWith("/")) {
|
|
264
415
|
normalizedPath = normalizedPath + "/";
|
|
265
416
|
}
|
|
@@ -293,6 +444,25 @@ export function route(path, handler) {
|
|
|
293
444
|
export function index(handler) {
|
|
294
445
|
return route("/", handler);
|
|
295
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Defines an error handler that catches errors from routes, middleware, and RSC actions.
|
|
449
|
+
*
|
|
450
|
+
* @example
|
|
451
|
+
* // Global error handler
|
|
452
|
+
* except((error, requestInfo) => {
|
|
453
|
+
* console.error(error);
|
|
454
|
+
* return new Response("Internal Server Error", { status: 500 });
|
|
455
|
+
* })
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* // Error handler that returns a React component
|
|
459
|
+
* except((error) => {
|
|
460
|
+
* return <ErrorPage error={error} />;
|
|
461
|
+
* })
|
|
462
|
+
*/
|
|
463
|
+
export function except(handler) {
|
|
464
|
+
return { __rwExcept: true, handler };
|
|
465
|
+
}
|
|
296
466
|
/**
|
|
297
467
|
* Prefixes a group of routes with a path.
|
|
298
468
|
*
|
|
@@ -312,25 +482,83 @@ export function index(handler) {
|
|
|
312
482
|
* ]),
|
|
313
483
|
* ])
|
|
314
484
|
*/
|
|
485
|
+
function joinPaths(p1, p2) {
|
|
486
|
+
// Normalize p1: ensure it doesn't end with / (except if it's just "/")
|
|
487
|
+
const part1 = p1 === "/" ? "/" : p1.endsWith("/") ? p1.slice(0, -1) : p1;
|
|
488
|
+
// Normalize p2: ensure it starts with /
|
|
489
|
+
const part2 = p2.startsWith("/") ? p2 : `/${p2}`;
|
|
490
|
+
return part1 + part2;
|
|
491
|
+
}
|
|
315
492
|
export function prefix(prefixPath, routes) {
|
|
493
|
+
// Normalize prefix path
|
|
494
|
+
let normalizedPrefix = prefixPath;
|
|
495
|
+
if (!normalizedPrefix.startsWith("/")) {
|
|
496
|
+
normalizedPrefix = "/" + normalizedPrefix;
|
|
497
|
+
}
|
|
498
|
+
if (!normalizedPrefix.endsWith("/")) {
|
|
499
|
+
normalizedPrefix = normalizedPrefix + "/";
|
|
500
|
+
}
|
|
501
|
+
// Check if prefix has parameters
|
|
502
|
+
const hasParams = normalizedPrefix.includes(":") || normalizedPrefix.includes("*");
|
|
503
|
+
// Create a pattern for matching: if prefix has params, append wildcard to match any path under it
|
|
504
|
+
const matchPattern = hasParams
|
|
505
|
+
? normalizedPrefix.endsWith("/")
|
|
506
|
+
? normalizedPrefix.slice(0, -1) + "/*"
|
|
507
|
+
: normalizedPrefix + "/*"
|
|
508
|
+
: normalizedPrefix;
|
|
316
509
|
const prefixed = routes.map((r) => {
|
|
317
510
|
if (typeof r === "function") {
|
|
318
511
|
const middleware = (requestInfo) => {
|
|
319
|
-
const
|
|
320
|
-
if
|
|
321
|
-
|
|
512
|
+
const path = requestInfo.path;
|
|
513
|
+
// Check if path matches the prefix pattern
|
|
514
|
+
let matches = false;
|
|
515
|
+
let prefixParams = {};
|
|
516
|
+
if (hasParams) {
|
|
517
|
+
// Use matchPath to check if path matches and extract params
|
|
518
|
+
const params = matchPath(matchPattern, path);
|
|
519
|
+
if (params) {
|
|
520
|
+
matches = true;
|
|
521
|
+
prefixParams = params;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
// For static prefixes, use simple string matching
|
|
526
|
+
if (path === normalizedPrefix || path.startsWith(normalizedPrefix)) {
|
|
527
|
+
matches = true;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (matches) {
|
|
531
|
+
// Merge prefix params with existing params
|
|
532
|
+
const mergedParams = { ...requestInfo.params, ...prefixParams };
|
|
533
|
+
// Create a new requestInfo with merged params
|
|
534
|
+
const modifiedRequestInfo = {
|
|
535
|
+
...requestInfo,
|
|
536
|
+
params: mergedParams,
|
|
537
|
+
};
|
|
538
|
+
return r(modifiedRequestInfo);
|
|
322
539
|
}
|
|
323
540
|
return;
|
|
324
541
|
};
|
|
325
542
|
return middleware;
|
|
326
543
|
}
|
|
544
|
+
if (typeof r === "object" &&
|
|
545
|
+
r !== null &&
|
|
546
|
+
"__rwExcept" in r &&
|
|
547
|
+
r.__rwExcept === true) {
|
|
548
|
+
// Pass through ExceptHandler as-is
|
|
549
|
+
return r;
|
|
550
|
+
}
|
|
327
551
|
if (Array.isArray(r)) {
|
|
328
552
|
// Recursively process nested route arrays
|
|
329
553
|
return prefix(prefixPath, r);
|
|
330
554
|
}
|
|
331
555
|
const routeDef = r;
|
|
556
|
+
// Use joinPaths to properly combine paths
|
|
557
|
+
const combinedPath = joinPaths(prefixPath, routeDef.path);
|
|
558
|
+
// Normalize double slashes
|
|
559
|
+
const normalizedCombinedPath = combinedPath.replace(/\/+/g, "/");
|
|
332
560
|
return {
|
|
333
|
-
path:
|
|
561
|
+
path: normalizedCombinedPath,
|
|
334
562
|
handler: routeDef.handler,
|
|
335
563
|
...(routeDef.layouts && { layouts: routeDef.layouts }),
|
|
336
564
|
};
|
|
@@ -383,7 +611,7 @@ export const wrapHandlerToThrowResponses = (handler) => {
|
|
|
383
611
|
*
|
|
384
612
|
* @example
|
|
385
613
|
* // Define a layout component
|
|
386
|
-
* function BlogLayout({ children }: { children
|
|
614
|
+
* function BlogLayout({ children }: { children?: React.ReactNode }) {
|
|
387
615
|
* return (
|
|
388
616
|
* <div>
|
|
389
617
|
* <nav>Blog Navigation</nav>
|
|
@@ -404,6 +632,13 @@ export function layout(LayoutComponent, routes) {
|
|
|
404
632
|
// Pass through middleware as-is
|
|
405
633
|
return route;
|
|
406
634
|
}
|
|
635
|
+
if (typeof route === "object" &&
|
|
636
|
+
route !== null &&
|
|
637
|
+
"__rwExcept" in route &&
|
|
638
|
+
route.__rwExcept === true) {
|
|
639
|
+
// Pass through ExceptHandler as-is
|
|
640
|
+
return route;
|
|
641
|
+
}
|
|
407
642
|
if (Array.isArray(route)) {
|
|
408
643
|
// Recursively process nested route arrays
|
|
409
644
|
return layout(LayoutComponent, route);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { defineRoutes, layout, matchPath, prefix, render, route, } from "./router";
|
|
3
|
+
import { defineRoutes, except, layout, matchPath, prefix, render, route, } from "./router";
|
|
4
4
|
describe("matchPath", () => {
|
|
5
5
|
// Test case 1: Static paths
|
|
6
6
|
it("should match static paths", () => {
|
|
@@ -62,6 +62,7 @@ describe("defineRoutes - Request Handling Behavior", () => {
|
|
|
62
62
|
const createMockDependencies = () => {
|
|
63
63
|
const mockRequestInfo = {
|
|
64
64
|
request: new Request("http://localhost:3000/"),
|
|
65
|
+
path: "/",
|
|
65
66
|
params: {},
|
|
66
67
|
ctx: {},
|
|
67
68
|
rw: {
|
|
@@ -293,6 +294,109 @@ describe("defineRoutes - Request Handling Behavior", () => {
|
|
|
293
294
|
expect(executionOrder).toEqual(["prefixedMiddleware"]);
|
|
294
295
|
expect(await response.text()).toBe("From prefixed middleware");
|
|
295
296
|
});
|
|
297
|
+
it("should pass prefix parameters to route handlers", async () => {
|
|
298
|
+
let capturedParams = null;
|
|
299
|
+
const TaskDetailPage = (requestInfo) => {
|
|
300
|
+
capturedParams = requestInfo.params;
|
|
301
|
+
return React.createElement("div", {}, "Task Detail");
|
|
302
|
+
};
|
|
303
|
+
const router = defineRoutes([
|
|
304
|
+
...prefix("/tasks/:containerId", [route("/", TaskDetailPage)]),
|
|
305
|
+
]);
|
|
306
|
+
const deps = createMockDependencies();
|
|
307
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/tasks/123/");
|
|
308
|
+
const request = new Request("http://localhost:3000/tasks/123/");
|
|
309
|
+
await router.handle({
|
|
310
|
+
request,
|
|
311
|
+
renderPage: deps.mockRenderPage,
|
|
312
|
+
getRequestInfo: deps.getRequestInfo,
|
|
313
|
+
onError: deps.onError,
|
|
314
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
315
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
316
|
+
});
|
|
317
|
+
expect(capturedParams).toEqual({ containerId: "123" });
|
|
318
|
+
});
|
|
319
|
+
it("should pass prefix parameters to middlewares within a parameterized prefix", async () => {
|
|
320
|
+
const executionOrder = [];
|
|
321
|
+
let capturedParams = null;
|
|
322
|
+
const prefixedMiddleware = (requestInfo) => {
|
|
323
|
+
executionOrder.push("prefixedMiddleware");
|
|
324
|
+
capturedParams = requestInfo.params;
|
|
325
|
+
};
|
|
326
|
+
const PageComponent = () => {
|
|
327
|
+
executionOrder.push("PageComponent");
|
|
328
|
+
return React.createElement("div", {}, "Page");
|
|
329
|
+
};
|
|
330
|
+
const router = defineRoutes([
|
|
331
|
+
...prefix("/tasks/:containerId", [
|
|
332
|
+
prefixedMiddleware,
|
|
333
|
+
route("/", PageComponent),
|
|
334
|
+
]),
|
|
335
|
+
]);
|
|
336
|
+
const deps = createMockDependencies();
|
|
337
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/tasks/123/");
|
|
338
|
+
const request = new Request("http://localhost:3000/tasks/123/");
|
|
339
|
+
await router.handle({
|
|
340
|
+
request,
|
|
341
|
+
renderPage: deps.mockRenderPage,
|
|
342
|
+
getRequestInfo: deps.getRequestInfo,
|
|
343
|
+
onError: deps.onError,
|
|
344
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
345
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
346
|
+
});
|
|
347
|
+
expect(executionOrder).toEqual(["prefixedMiddleware", "PageComponent"]);
|
|
348
|
+
// The wildcard captures the trailing slash as an empty string
|
|
349
|
+
expect(capturedParams).toEqual({ containerId: "123", $0: "" });
|
|
350
|
+
});
|
|
351
|
+
it("should pass prefix parameters to route handlers (array)", async () => {
|
|
352
|
+
let capturedParamsInMiddleware = null;
|
|
353
|
+
let capturedParamsInComponent = null;
|
|
354
|
+
const middleware = (requestInfo) => {
|
|
355
|
+
capturedParamsInMiddleware = requestInfo.params;
|
|
356
|
+
};
|
|
357
|
+
const Component = (requestInfo) => {
|
|
358
|
+
capturedParamsInComponent = requestInfo.params;
|
|
359
|
+
return React.createElement("div", {}, "Component");
|
|
360
|
+
};
|
|
361
|
+
const router = defineRoutes([
|
|
362
|
+
...prefix("/tasks/:containerId", [route("/", [middleware, Component])]),
|
|
363
|
+
]);
|
|
364
|
+
const deps = createMockDependencies();
|
|
365
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/tasks/123/");
|
|
366
|
+
const request = new Request("http://localhost:3000/tasks/123/");
|
|
367
|
+
await router.handle({
|
|
368
|
+
request,
|
|
369
|
+
renderPage: deps.mockRenderPage,
|
|
370
|
+
getRequestInfo: deps.getRequestInfo,
|
|
371
|
+
onError: deps.onError,
|
|
372
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
373
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
374
|
+
});
|
|
375
|
+
expect(capturedParamsInMiddleware).toEqual({ containerId: "123" });
|
|
376
|
+
expect(capturedParamsInComponent).toEqual({ containerId: "123" });
|
|
377
|
+
});
|
|
378
|
+
it("should match even if prefix has a trailing slash", async () => {
|
|
379
|
+
let capturedParams = null;
|
|
380
|
+
const TaskDetailPage = (requestInfo) => {
|
|
381
|
+
capturedParams = requestInfo.params;
|
|
382
|
+
return React.createElement("div", {}, "Task Detail");
|
|
383
|
+
};
|
|
384
|
+
const router = defineRoutes([
|
|
385
|
+
...prefix("/tasks/:containerId/", [route("/", TaskDetailPage)]),
|
|
386
|
+
]);
|
|
387
|
+
const deps = createMockDependencies();
|
|
388
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/tasks/123/");
|
|
389
|
+
const request = new Request("http://localhost:3000/tasks/123/");
|
|
390
|
+
await router.handle({
|
|
391
|
+
request,
|
|
392
|
+
renderPage: deps.mockRenderPage,
|
|
393
|
+
getRequestInfo: deps.getRequestInfo,
|
|
394
|
+
onError: deps.onError,
|
|
395
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
396
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
397
|
+
});
|
|
398
|
+
expect(capturedParams).toEqual({ containerId: "123" });
|
|
399
|
+
});
|
|
296
400
|
});
|
|
297
401
|
describe("RSC Action Handling", () => {
|
|
298
402
|
it("should handle RSC actions before the first route definition", async () => {
|
|
@@ -884,4 +988,242 @@ describe("defineRoutes - Request Handling Behavior", () => {
|
|
|
884
988
|
expect(await response.text()).toBe("Rendered: Element");
|
|
885
989
|
});
|
|
886
990
|
});
|
|
991
|
+
describe("except - Error Handling", () => {
|
|
992
|
+
it("should catch errors from global middleware", async () => {
|
|
993
|
+
const errorMessage = "Middleware error";
|
|
994
|
+
const middleware = () => {
|
|
995
|
+
throw new Error(errorMessage);
|
|
996
|
+
};
|
|
997
|
+
const errorHandler = except((error) => {
|
|
998
|
+
return new Response(`Caught: ${error instanceof Error ? error.message : String(error)}`, {
|
|
999
|
+
status: 500,
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
const router = defineRoutes([
|
|
1003
|
+
middleware,
|
|
1004
|
+
errorHandler,
|
|
1005
|
+
route("/test/", () => React.createElement("div")),
|
|
1006
|
+
]);
|
|
1007
|
+
const deps = createMockDependencies();
|
|
1008
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/test/");
|
|
1009
|
+
const request = new Request("http://localhost:3000/test/");
|
|
1010
|
+
const response = await router.handle({
|
|
1011
|
+
request,
|
|
1012
|
+
renderPage: deps.mockRenderPage,
|
|
1013
|
+
getRequestInfo: deps.getRequestInfo,
|
|
1014
|
+
onError: deps.onError,
|
|
1015
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
1016
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
1017
|
+
});
|
|
1018
|
+
expect(response.status).toBe(500);
|
|
1019
|
+
expect(await response.text()).toBe(`Caught: ${errorMessage}`);
|
|
1020
|
+
});
|
|
1021
|
+
it("should catch errors from route handlers (components)", async () => {
|
|
1022
|
+
const errorMessage = "Component error";
|
|
1023
|
+
const PageComponent = () => {
|
|
1024
|
+
throw new Error(errorMessage);
|
|
1025
|
+
};
|
|
1026
|
+
const errorHandler = except((error) => {
|
|
1027
|
+
return new Response(`Caught: ${error instanceof Error ? error.message : String(error)}`, {
|
|
1028
|
+
status: 500,
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
const router = defineRoutes([
|
|
1032
|
+
errorHandler,
|
|
1033
|
+
route("/test/", PageComponent),
|
|
1034
|
+
]);
|
|
1035
|
+
const deps = createMockDependencies();
|
|
1036
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/test/");
|
|
1037
|
+
const request = new Request("http://localhost:3000/test/");
|
|
1038
|
+
const response = await router.handle({
|
|
1039
|
+
request,
|
|
1040
|
+
renderPage: deps.mockRenderPage,
|
|
1041
|
+
getRequestInfo: deps.getRequestInfo,
|
|
1042
|
+
onError: deps.onError,
|
|
1043
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
1044
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
1045
|
+
});
|
|
1046
|
+
expect(response.status).toBe(500);
|
|
1047
|
+
expect(await response.text()).toBe(`Caught: ${errorMessage}`);
|
|
1048
|
+
});
|
|
1049
|
+
it("should catch errors from route handlers (functions)", async () => {
|
|
1050
|
+
const errorMessage = "Handler error";
|
|
1051
|
+
const routeHandler = () => {
|
|
1052
|
+
throw new Error(errorMessage);
|
|
1053
|
+
};
|
|
1054
|
+
const errorHandler = except((error) => {
|
|
1055
|
+
return new Response(`Caught: ${error instanceof Error ? error.message : String(error)}`, {
|
|
1056
|
+
status: 500,
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
const router = defineRoutes([
|
|
1060
|
+
errorHandler,
|
|
1061
|
+
route("/test/", routeHandler),
|
|
1062
|
+
]);
|
|
1063
|
+
const deps = createMockDependencies();
|
|
1064
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/test/");
|
|
1065
|
+
const request = new Request("http://localhost:3000/test/");
|
|
1066
|
+
const response = await router.handle({
|
|
1067
|
+
request,
|
|
1068
|
+
renderPage: deps.mockRenderPage,
|
|
1069
|
+
getRequestInfo: deps.getRequestInfo,
|
|
1070
|
+
onError: deps.onError,
|
|
1071
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
1072
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
1073
|
+
});
|
|
1074
|
+
expect(response.status).toBe(500);
|
|
1075
|
+
expect(await response.text()).toBe(`Caught: ${errorMessage}`);
|
|
1076
|
+
});
|
|
1077
|
+
it("should catch errors from RSC actions", async () => {
|
|
1078
|
+
const errorMessage = "RSC action error";
|
|
1079
|
+
const errorHandler = except((error) => {
|
|
1080
|
+
return new Response(`Caught: ${error instanceof Error ? error.message : String(error)}`, {
|
|
1081
|
+
status: 500,
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
const router = defineRoutes([
|
|
1085
|
+
errorHandler,
|
|
1086
|
+
route("/test/", () => React.createElement("div")),
|
|
1087
|
+
]);
|
|
1088
|
+
const deps = createMockDependencies();
|
|
1089
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/test/?__rsc_action_id=test");
|
|
1090
|
+
deps.mockRscActionHandler = async () => {
|
|
1091
|
+
throw new Error(errorMessage);
|
|
1092
|
+
};
|
|
1093
|
+
const request = new Request("http://localhost:3000/test/?__rsc_action_id=test");
|
|
1094
|
+
const response = await router.handle({
|
|
1095
|
+
request,
|
|
1096
|
+
renderPage: deps.mockRenderPage,
|
|
1097
|
+
getRequestInfo: deps.getRequestInfo,
|
|
1098
|
+
onError: deps.onError,
|
|
1099
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
1100
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
1101
|
+
});
|
|
1102
|
+
expect(response.status).toBe(500);
|
|
1103
|
+
expect(await response.text()).toBe(`Caught: ${errorMessage}`);
|
|
1104
|
+
});
|
|
1105
|
+
it("should use the most recent except handler before the error", async () => {
|
|
1106
|
+
const errorMessage = "Route error";
|
|
1107
|
+
const firstHandler = except((error) => {
|
|
1108
|
+
return new Response("First handler", { status: 500 });
|
|
1109
|
+
});
|
|
1110
|
+
const secondHandler = except((error) => {
|
|
1111
|
+
return new Response("Second handler", { status: 500 });
|
|
1112
|
+
});
|
|
1113
|
+
const PageComponent = () => {
|
|
1114
|
+
throw new Error(errorMessage);
|
|
1115
|
+
};
|
|
1116
|
+
const router = defineRoutes([
|
|
1117
|
+
firstHandler,
|
|
1118
|
+
secondHandler,
|
|
1119
|
+
route("/test/", PageComponent),
|
|
1120
|
+
]);
|
|
1121
|
+
const deps = createMockDependencies();
|
|
1122
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/test/");
|
|
1123
|
+
const request = new Request("http://localhost:3000/test/");
|
|
1124
|
+
const response = await router.handle({
|
|
1125
|
+
request,
|
|
1126
|
+
renderPage: deps.mockRenderPage,
|
|
1127
|
+
getRequestInfo: deps.getRequestInfo,
|
|
1128
|
+
onError: deps.onError,
|
|
1129
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
1130
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
1131
|
+
});
|
|
1132
|
+
// Should use the second handler (most recent before the route)
|
|
1133
|
+
expect(response.status).toBe(500);
|
|
1134
|
+
expect(await response.text()).toBe("Second handler");
|
|
1135
|
+
});
|
|
1136
|
+
it("should try next except handler if current one throws", async () => {
|
|
1137
|
+
const errorMessage = "Route error";
|
|
1138
|
+
const firstHandler = except(() => {
|
|
1139
|
+
throw new Error("First handler error");
|
|
1140
|
+
});
|
|
1141
|
+
const secondHandler = except((error) => {
|
|
1142
|
+
return new Response(`Caught by second: ${error instanceof Error ? error.message : String(error)}`, {
|
|
1143
|
+
status: 500,
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
const PageComponent = () => {
|
|
1147
|
+
throw new Error(errorMessage);
|
|
1148
|
+
};
|
|
1149
|
+
const router = defineRoutes([
|
|
1150
|
+
secondHandler, // Outer handler
|
|
1151
|
+
firstHandler, // Inner handler (closer to route)
|
|
1152
|
+
route("/test/", PageComponent),
|
|
1153
|
+
]);
|
|
1154
|
+
const deps = createMockDependencies();
|
|
1155
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/test/");
|
|
1156
|
+
const request = new Request("http://localhost:3000/test/");
|
|
1157
|
+
const response = await router.handle({
|
|
1158
|
+
request,
|
|
1159
|
+
renderPage: deps.mockRenderPage,
|
|
1160
|
+
getRequestInfo: deps.getRequestInfo,
|
|
1161
|
+
onError: deps.onError,
|
|
1162
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
1163
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
1164
|
+
});
|
|
1165
|
+
// Should catch the error from the first handler with the second handler
|
|
1166
|
+
expect(response.status).toBe(500);
|
|
1167
|
+
expect(await response.text()).toBe("Caught by second: First handler error");
|
|
1168
|
+
});
|
|
1169
|
+
it("should return JSX element from except handler", async () => {
|
|
1170
|
+
const errorMessage = "Route error";
|
|
1171
|
+
function ErrorComponent() {
|
|
1172
|
+
return React.createElement("div", {}, "Error Page");
|
|
1173
|
+
}
|
|
1174
|
+
const errorHandler = except(() => {
|
|
1175
|
+
return React.createElement(ErrorComponent);
|
|
1176
|
+
});
|
|
1177
|
+
const PageComponent = () => {
|
|
1178
|
+
throw new Error(errorMessage);
|
|
1179
|
+
};
|
|
1180
|
+
const router = defineRoutes([
|
|
1181
|
+
errorHandler,
|
|
1182
|
+
route("/test/", PageComponent),
|
|
1183
|
+
]);
|
|
1184
|
+
const deps = createMockDependencies();
|
|
1185
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/test/");
|
|
1186
|
+
const request = new Request("http://localhost:3000/test/");
|
|
1187
|
+
const response = await router.handle({
|
|
1188
|
+
request,
|
|
1189
|
+
renderPage: deps.mockRenderPage,
|
|
1190
|
+
getRequestInfo: deps.getRequestInfo,
|
|
1191
|
+
onError: deps.onError,
|
|
1192
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
1193
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
1194
|
+
});
|
|
1195
|
+
expect(await response.text()).toBe("Rendered: ErrorComponent");
|
|
1196
|
+
});
|
|
1197
|
+
it("should work with prefix and layout", async () => {
|
|
1198
|
+
const errorMessage = "Route error";
|
|
1199
|
+
const errorHandler = except((error) => {
|
|
1200
|
+
return new Response(`Caught: ${error instanceof Error ? error.message : String(error)}`, {
|
|
1201
|
+
status: 500,
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
const PageComponent = () => {
|
|
1205
|
+
throw new Error(errorMessage);
|
|
1206
|
+
};
|
|
1207
|
+
const Layout = ({ children }) => {
|
|
1208
|
+
return React.createElement("div", {}, children);
|
|
1209
|
+
};
|
|
1210
|
+
const router = defineRoutes([
|
|
1211
|
+
errorHandler,
|
|
1212
|
+
layout(Layout, [prefix("/api", [route("/test/", PageComponent)])]),
|
|
1213
|
+
]);
|
|
1214
|
+
const deps = createMockDependencies();
|
|
1215
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/api/test/");
|
|
1216
|
+
const request = new Request("http://localhost:3000/api/test/");
|
|
1217
|
+
const response = await router.handle({
|
|
1218
|
+
request,
|
|
1219
|
+
renderPage: deps.mockRenderPage,
|
|
1220
|
+
getRequestInfo: deps.getRequestInfo,
|
|
1221
|
+
onError: deps.onError,
|
|
1222
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
1223
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
1224
|
+
});
|
|
1225
|
+
expect(response.status).toBe(500);
|
|
1226
|
+
expect(await response.text()).toBe(`Caught: ${errorMessage}`);
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
887
1229
|
});
|
|
@@ -9,6 +9,7 @@ export const constructWithDefaultRequestInfo = (overrides = {}) => {
|
|
|
9
9
|
const { rw: rwOverrides, ...otherRequestInfoOverrides } = overrides;
|
|
10
10
|
const defaultRequestInfo = {
|
|
11
11
|
request: new Request("http://localhost/"),
|
|
12
|
+
path: "/",
|
|
12
13
|
params: {},
|
|
13
14
|
ctx: {},
|
|
14
15
|
cf: {
|
|
@@ -17,7 +17,8 @@ export const requestInfo = Object.freeze(requestInfoBase);
|
|
|
17
17
|
export function getRequestInfo() {
|
|
18
18
|
const store = requestInfoStore.getStore();
|
|
19
19
|
if (!store) {
|
|
20
|
-
throw new Error("Request context not found"
|
|
20
|
+
throw new Error("RedwoodSDK: Request context not found. getRequestInfo() can only be called within the request lifecycle (e.g., in a route handler, middleware, or server action).\n\n" +
|
|
21
|
+
"For detailed troubleshooting steps, see: https://docs.rwsdk.com/guides/troubleshooting#request-context-errors");
|
|
21
22
|
}
|
|
22
23
|
return store;
|
|
23
24
|
}
|
package/dist/runtime/worker.js
CHANGED
|
@@ -11,11 +11,11 @@ import { defineRoutes } from "./lib/router";
|
|
|
11
11
|
import { generateNonce } from "./lib/utils";
|
|
12
12
|
export * from "./requestInfo/types";
|
|
13
13
|
export const defineApp = (routes) => {
|
|
14
|
+
const router = defineRoutes(routes);
|
|
14
15
|
return {
|
|
15
16
|
__rwRoutes: routes,
|
|
16
17
|
fetch: async (request, env, cf) => {
|
|
17
18
|
globalThis.__webpack_require__ = ssrWebpackRequire;
|
|
18
|
-
const router = defineRoutes(routes);
|
|
19
19
|
// context(justinvdm, 5 Feb 2025): Serve assets requests using the assets service binding
|
|
20
20
|
// todo(justinvdm, 5 Feb 2025): Find a way to avoid this so asset requests are served directly
|
|
21
21
|
// rather than first needing to go through the worker
|
|
@@ -63,6 +63,10 @@ export const defineApp = (routes) => {
|
|
|
63
63
|
}
|
|
64
64
|
try {
|
|
65
65
|
const url = new URL(request.url);
|
|
66
|
+
let path = url.pathname;
|
|
67
|
+
if (path !== "/" && !path.endsWith("/")) {
|
|
68
|
+
path = path + "/";
|
|
69
|
+
}
|
|
66
70
|
const isRSCRequest = url.searchParams.has("__rsc") ||
|
|
67
71
|
request.headers.get("accept")?.includes("text/x-component");
|
|
68
72
|
const isAction = url.searchParams.has("__rsc_action_id");
|
|
@@ -83,6 +87,7 @@ export const defineApp = (routes) => {
|
|
|
83
87
|
};
|
|
84
88
|
const outerRequestInfo = {
|
|
85
89
|
request,
|
|
90
|
+
path,
|
|
86
91
|
cf,
|
|
87
92
|
params: {},
|
|
88
93
|
ctx: {},
|
|
@@ -298,7 +298,9 @@ export const runDirectivesScan = async ({ rootConfig, environments, clientFiles,
|
|
|
298
298
|
});
|
|
299
299
|
}
|
|
300
300
|
catch (e) {
|
|
301
|
-
throw new Error(`
|
|
301
|
+
throw new Error(`RedwoodSDK: Directive scan failed. This often happens due to syntax errors in files using "use client" or "use server". Check your directive files for issues.\n\n` +
|
|
302
|
+
`For detailed troubleshooting steps, see: https://docs.rwsdk.com/guides/troubleshooting#directive-scan-errors\n\n` +
|
|
303
|
+
`${e.stack}`);
|
|
302
304
|
}
|
|
303
305
|
finally {
|
|
304
306
|
deferredLog("✔ (rwsdk) Done scanning for 'use client' and 'use server' directives.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rwsdk",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.44",
|
|
4
4
|
"description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -182,6 +182,7 @@
|
|
|
182
182
|
"puppeteer-core": "~24.22.0",
|
|
183
183
|
"react-is": "~19.1.0",
|
|
184
184
|
"rsc-html-stream": "~0.0.6",
|
|
185
|
+
"server-only": "^0.0.1",
|
|
185
186
|
"tmp-promise": "~3.0.3",
|
|
186
187
|
"ts-morph": "~27.0.0",
|
|
187
188
|
"unique-names-generator": "~4.7.1",
|