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.
@@ -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('no element with id "hydrate-root"');
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("rwsdk: 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.");
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("rwsdk: 'react-server' is not supported in this environment");
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("rwsdk: 'react-server' import condition needs to be used in this environment");
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.");
@@ -1,3 +1,4 @@
1
+ import "server-only";
1
2
  import "./types/worker";
2
3
  export * from "../error";
3
4
  export * from "../lib/types";
@@ -1,3 +1,4 @@
1
+ import "server-only";
1
2
  import "./types/worker";
2
3
  export * from "../error";
3
4
  export * from "../lib/types";
@@ -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 strings.`);
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
- * Prefixes a group of routes with a path.
111
+ * Defines an error handler that catches errors from routes, middleware, and RSC actions.
108
112
  *
109
113
  * @example
110
- * // Organize blog routes under /blog
111
- * const blogRoutes = [
112
- * route("/", () => <BlogIndex />),
113
- * route("/post/:id", ({ params }) => <BlogPost id={params.id} />),
114
- * route("/admin", [isAuthenticated, () => <BlogAdmin />]),
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
- * // In worker.tsx
118
- * defineApp([
119
- * render(Document, [
120
- * route("/", () => <HomePage />),
121
- * prefix("/blog", blogRoutes),
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: React.ReactNode }) {
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
- export function matchPath(routePath, requestPath) {
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 pattern = routePath
19
- .replace(/:[a-zA-Z0-9]+/g, "([^/]+)") // Convert :param to capture group
20
- .replace(/\*/g, "(.*)"); // Convert * to wildcard capture group
21
- const regex = new RegExp(`^${pattern}$`);
22
- const matches = requestPath.match(regex);
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
- // Revised parameter extraction:
27
- const params = {};
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
- // This token is a named parameter (e.g., matchToken[1] is "id" for ":id")
45
- params[matchToken[1]] = matches[currentMatchIndex];
34
+ paramMap.push({ name: matchToken[1], isWildcard: false });
46
35
  }
47
36
  else {
48
- // This token is a wildcard "*"
49
- params[`$${wildcardCounter}`] = matches[currentMatchIndex];
50
- wildcardCounter++;
37
+ paramMap.push({ name: `$${wildcardCounter++}`, isWildcard: true });
51
38
  }
52
- currentMatchIndex++;
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 [...acc, route];
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
- if (!actionHandled && url.searchParams.has("__rsc_action_id")) {
137
- getRequestInfo().rw.actionResult = await rscActionHandler(request);
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
- for (const route of flattenedRoutes) {
142
- if (typeof route === "function") {
143
- // This is a global middleware.
144
- const result = await route(getRequestInfo());
145
- const handled = await handleMiddlewareResult(result);
146
- if (handled) {
147
- return handled; // Short-circuit
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
- // Try to find handler for the request method
171
- handler = getHandlerForMethod(route.handler, requestMethod);
172
- if (!handler) {
173
- // Method not supported for this route
174
- if (!route.handler.config?.disable405) {
175
- return handleMethodNotAllowed(route.handler);
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
- // If 405 is disabled, continue to next route
241
+ currentRouteIndex++;
178
242
  continue;
179
243
  }
180
- }
181
- else {
182
- handler = route.handler;
183
- }
184
- // Found a match: run route-specific middlewares, then the final component, then stop.
185
- return await runWithRequestInfoOverrides({ params }, async () => {
186
- const { routeMiddlewares, componentHandler } = parseHandlers(handler);
187
- // Route-specific middlewares
188
- for (const mw of routeMiddlewares) {
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
- // Final component/handler
196
- if (isRouteComponent(componentHandler)) {
197
- const requestInfo = getRequestInfo();
198
- const WrappedComponent = wrapWithLayouts(wrapHandlerToThrowResponses(componentHandler), route.layouts || [], requestInfo);
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
- // Handle non-component final handler (e.g., returns new Response)
205
- const tailResult = await componentHandler(getRequestInfo());
206
- const handledTail = await handleMiddlewareResult(tailResult);
207
- if (handledTail) {
208
- return handledTail;
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
- return new Response("Response not returned from route handler", {
211
- status: 500,
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
- // If we've gotten this far, no route was matched.
216
- // We still need to handle a possible action if the app has no route definitions at all.
217
- if (!firstRouteDefinitionEncountered) {
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 url = new URL(requestInfo.request.url);
320
- if (url.pathname.startsWith(prefixPath)) {
321
- return r(requestInfo);
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: prefixPath + routeDef.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: React.ReactNode }) {
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
  });
@@ -3,6 +3,7 @@ export interface DefaultAppContext {
3
3
  }
4
4
  export interface RequestInfo<Params = any, AppContext = DefaultAppContext> {
5
5
  request: Request;
6
+ path: string;
6
7
  params: Params;
7
8
  ctx: AppContext;
8
9
  rw: RwContext;
@@ -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
  }
@@ -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(`RWSDK directive scan failed:\n${e.stack}`);
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.43",
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",