From 8d45f68bbbefd4dbff0365a69564b29ef1c1d0b5 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Wed, 1 Jul 2026 15:52:28 +0000 Subject: [PATCH] fix: handle redirects for unmatched routes --- packages/cli/src/prebuild.test.ts | 32 +++++++++++++++++++++++++++++++ packages/cli/src/prebuild.ts | 31 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/packages/cli/src/prebuild.test.ts b/packages/cli/src/prebuild.test.ts index 672d6cee464c..0c2ef3b2000b 100644 --- a/packages/cli/src/prebuild.test.ts +++ b/packages/cli/src/prebuild.test.ts @@ -5,9 +5,11 @@ import { readdir, readFile, rm, + symlink, writeFile, } from "node:fs/promises"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { tmpdir } from "node:os"; import { bundleVersion } from "@webstudio-is/protocol"; import { generateRedirectsModule, prebuild } from "./prebuild"; @@ -20,6 +22,35 @@ const rootFolderId = "root"; const elementComponent = "ws:element"; const slowPrebuildTestTimeout = 15_000; type Redirects = Array<{ old: string; new: string; status?: "301" | "302" }>; +type GeneratedRouteModule = { + loader: (args: { request: Request }) => Response | Promise; +}; + +const importGeneratedRoute = async (path: string) => { + await symlink(join(originalCwd, "node_modules"), "node_modules", "dir"); + return (await import( + `${pathToFileURL(join(tempDir, path)).href}?test=${crypto.randomUUID()}` + )) as GeneratedRouteModule; +}; + +const expectGeneratedRedirectFallback = async (path: string) => { + const routeModule = await importGeneratedRoute(path); + const redirectResponse = await routeModule.loader({ + request: new Request("https://example.com/dl.php?filename=file.pdf"), + }); + expect(redirectResponse.status).toBe(301); + expect(redirectResponse.headers.get("Location")).toBe("/downloads/file.pdf"); + + try { + await routeModule.loader({ + request: new Request("https://example.com/not-a-redirect"), + }); + throw new Error("Expected unmatched request to throw a 404 response."); + } catch (error) { + expect(error).toBeInstanceOf(Response); + expect((error as Response).status).toBe(404); + } +}; const getFilePaths = async (dir: string): Promise => { const entries = await readdir(dir, { withFileTypes: true }); @@ -302,6 +333,7 @@ describe("prebuild", () => { expect(routeTemplate).toContain("../__generated__/_index.server"); expect(routeTemplate).not.toContain("__CLIENT__"); expect(routeTemplate).not.toContain("__SERVER__"); + await expectGeneratedRedirectFallback("app/routes/$.tsx"); await expect( readFile("app/__generated__/stale.ts", "utf8") diff --git a/packages/cli/src/prebuild.ts b/packages/cli/src/prebuild.ts index 92d087880fb8..5baac98e8528 100644 --- a/packages/cli/src/prebuild.ts +++ b/packages/cli/src/prebuild.ts @@ -196,6 +196,28 @@ export const generateRedirectsModule = (pageRedirects: Pages["redirects"]) => { `; }; +const generateRedirectFallbackRoute = (runtime: "remix" | "react-router") => { + const loaderFunctionArgs = + runtime === "react-router" ? "react-router" : "@remix-run/server-runtime"; + + return ` + import { type LoaderFunctionArgs } from ${JSON.stringify(loaderFunctionArgs)}; + import { redirectRequest } from "../redirect-url"; + // @todo think about how to make __generated__ typeable + // @ts-ignore + import { redirects } from "../__generated__/$resources.redirects"; + + export const loader = ({ request }: LoaderFunctionArgs) => { + const redirectResponse = redirectRequest(request, redirects); + if (redirectResponse !== undefined) { + return redirectResponse; + } + + throw new Response("Not Found", { status: 404 }); + }; + `; +}; + export const prebuild = async (options: { /** * Do we need download assets @@ -732,6 +754,15 @@ export const prebuild = async (options: { generateRedirectsModule(pages.redirects) ); + if (pages.redirects !== undefined && pages.redirects.length > 0) { + await createFileIfNotExists( + join(routesDir, "$.tsx"), + generateRedirectFallbackRoute( + options.template.includes("react-router") ? "react-router" : "remix" + ) + ); + } + if (options.assets === true && siteData.assets.length > 0) { const downloading = spinner(); downloading.start("Downloading fonts and images");