diff --git a/fixtures/react-router-cloudflare/workers/app.ts b/fixtures/react-router-cloudflare/workers/app.ts index 6d6f86672208..df05b6592566 100644 --- a/fixtures/react-router-cloudflare/workers/app.ts +++ b/fixtures/react-router-cloudflare/workers/app.ts @@ -1,4 +1,9 @@ import { createRequestHandler } from "react-router"; +// @ts-ignore - app files are provided by the react-router template +import { redirectRequest } from "../app/redirect-url"; +// @todo think about how to make __generated__ typeable +// @ts-ignore +import { redirects } from "../app/__generated__/$resources.redirects"; declare module "react-router" { export interface AppLoadContext { @@ -17,6 +22,11 @@ const requestHandler = createRequestHandler( export default { async fetch(request, env, ctx) { + const redirectResponse = redirectRequest(request, redirects); + if (redirectResponse !== undefined) { + return redirectResponse; + } + return requestHandler(request, { EXCLUDE_FROM_SEARCH: false, getDefaultActionResource: undefined, diff --git a/fixtures/webstudio-cloudflare-template/functions/[[path]].ts b/fixtures/webstudio-cloudflare-template/functions/[[path]].ts index 943b55594f13..d8de1b18d0d9 100644 --- a/fixtures/webstudio-cloudflare-template/functions/[[path]].ts +++ b/fixtures/webstudio-cloudflare-template/functions/[[path]].ts @@ -1,4 +1,9 @@ import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages"; +// @ts-ignore - app files are provided by the defaults template +import { redirectRequest } from "../app/redirect-url"; +// @todo think about how to make __generated__ typeable +// @ts-ignore +import { redirects } from "../app/__generated__/$resources.redirects"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - the server build file is generated by `remix vite:build` @@ -6,4 +11,13 @@ import * as build from "../build/server"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - the server build file is generated by `remix vite:build` -export const onRequest = createPagesFunctionHandler({ build }); +const requestHandler = createPagesFunctionHandler({ build }); + +export const onRequest: PagesFunction = (context) => { + const redirectResponse = redirectRequest(context.request, redirects); + if (redirectResponse !== undefined) { + return redirectResponse; + } + + return requestHandler(context); +}; diff --git a/packages/cli/src/cloudflare-redirect-entrypoint.test.ts b/packages/cli/src/cloudflare-redirect-entrypoint.test.ts new file mode 100644 index 000000000000..34363296a0f6 --- /dev/null +++ b/packages/cli/src/cloudflare-redirect-entrypoint.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const cliRoot = process.cwd(); + +const writeModule = async (path: string, contents: string) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents, "utf8"); +}; + +const importTempModule = async (path: string) => { + return (await import( + `${pathToFileURL(path).href}?test=${crypto.randomUUID()}` + )) as Module; +}; + +const writeTempApp = async (tempDir: string) => { + await writeModule( + join(tempDir, "app/redirect-url.ts"), + ` + export const redirectRequest = (request, redirects) => { + const pathname = new URL(request.url).pathname; + const redirect = redirects.find((redirect) => redirect.old === pathname); + if (redirect === undefined) { + return; + } + return new Response(null, { + status: redirect.status, + headers: { Location: redirect.new }, + }); + }; + ` + ); + await writeModule( + join(tempDir, "app/__generated__/$resources.redirects.ts"), + `export const redirects = [{ old: "/missing", new: "/target", status: 301 }];` + ); + + await writeModule( + join(tempDir, "workers/app.ts"), + await readFile( + join(cliRoot, "templates/react-router-cloudflare/workers/app.ts"), + "utf8" + ) + ); +}; + +describe("cloudflare redirect entrypoint", () => { + test("redirects before unmatched routes reach react-router", async () => { + const tempDir = await mkdtemp(join(cliRoot, ".tmp-cloudflare-redirect-")); + try { + await writeTempApp(tempDir); + const worker = await importTempModule<{ + default: { + fetch: ( + request: Request, + env: Record, + ctx: Record + ) => Response | Promise; + }; + }>(join(tempDir, "workers/app.ts")); + const response = await worker.default.fetch( + new Request("https://example.com/missing"), + {}, + {} + ); + + expect(response.status).toBe(301); + expect(response.headers.get("Location")).toBe("/target"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/redirect-url.test.ts b/packages/cli/src/redirect-url.test.ts index 67757e6b15a9..d3e667789eaa 100644 --- a/packages/cli/src/redirect-url.test.ts +++ b/packages/cli/src/redirect-url.test.ts @@ -85,6 +85,37 @@ describe("redirect-url fixture copies", () => { ) ); }); + + test("keeps react-router-cloudflare worker redirect handling synced with template", async () => { + await expect( + readFile( + join(repoRoot, "fixtures/react-router-cloudflare/workers/app.ts"), + "utf8" + ) + ).resolves.toEqual( + await readFile( + join(cliRoot, "templates/react-router-cloudflare/workers/app.ts"), + "utf8" + ) + ); + }); + + test("keeps webstudio-cloudflare-template function redirect handling synced with template", async () => { + await expect( + readFile( + join( + repoRoot, + "fixtures/webstudio-cloudflare-template/functions/[[path]].ts" + ), + "utf8" + ) + ).resolves.toEqual( + await readFile( + join(cliRoot, "templates/cloudflare/functions/[[path]].ts"), + "utf8" + ) + ); + }); }); for (const { diff --git a/packages/cli/templates/cloudflare/functions/[[path]].ts b/packages/cli/templates/cloudflare/functions/[[path]].ts index 943b55594f13..d8de1b18d0d9 100644 --- a/packages/cli/templates/cloudflare/functions/[[path]].ts +++ b/packages/cli/templates/cloudflare/functions/[[path]].ts @@ -1,4 +1,9 @@ import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages"; +// @ts-ignore - app files are provided by the defaults template +import { redirectRequest } from "../app/redirect-url"; +// @todo think about how to make __generated__ typeable +// @ts-ignore +import { redirects } from "../app/__generated__/$resources.redirects"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - the server build file is generated by `remix vite:build` @@ -6,4 +11,13 @@ import * as build from "../build/server"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - the server build file is generated by `remix vite:build` -export const onRequest = createPagesFunctionHandler({ build }); +const requestHandler = createPagesFunctionHandler({ build }); + +export const onRequest: PagesFunction = (context) => { + const redirectResponse = redirectRequest(context.request, redirects); + if (redirectResponse !== undefined) { + return redirectResponse; + } + + return requestHandler(context); +}; diff --git a/packages/cli/templates/react-router-cloudflare/workers/app.ts b/packages/cli/templates/react-router-cloudflare/workers/app.ts index 6d6f86672208..df05b6592566 100644 --- a/packages/cli/templates/react-router-cloudflare/workers/app.ts +++ b/packages/cli/templates/react-router-cloudflare/workers/app.ts @@ -1,4 +1,9 @@ import { createRequestHandler } from "react-router"; +// @ts-ignore - app files are provided by the react-router template +import { redirectRequest } from "../app/redirect-url"; +// @todo think about how to make __generated__ typeable +// @ts-ignore +import { redirects } from "../app/__generated__/$resources.redirects"; declare module "react-router" { export interface AppLoadContext { @@ -17,6 +22,11 @@ const requestHandler = createRequestHandler( export default { async fetch(request, env, ctx) { + const redirectResponse = redirectRequest(request, redirects); + if (redirectResponse !== undefined) { + return redirectResponse; + } + return requestHandler(request, { EXCLUDE_FROM_SEARCH: false, getDefaultActionResource: undefined,