diff --git a/docs/canary/the-canary-version/rewrites.md b/docs/canary/the-canary-version/rewrites.md new file mode 100644 index 00000000000..57e9d7dbc99 --- /dev/null +++ b/docs/canary/the-canary-version/rewrites.md @@ -0,0 +1,80 @@ +--- +description: | + Use ctx.rewrite() to internally resolve a different route without redirecting the client. +--- + +Use `ctx.rewrite()` to handle a request under a different route internally, +without sending an HTTP redirect to the browser. The browser URL stays the same, +but Fresh rematches and handles the rewritten path. + +```ts +app.use((ctx) => { + if (ctx.url.pathname.startsWith("/legacy/")) { + const pathname = ctx.url.pathname.replace("/legacy", ""); + return ctx.rewrite(pathname); + } + + return ctx.next(); +}); +``` + +## Same-origin only + +`ctx.rewrite()` only accepts same-origin targets. Passing a cross-origin URL +throws an error. + +## Query parameters + +When the target is a string without a `?query`, Fresh keeps the current query +parameters on the rewritten request. + +## basePath + +When `basePath` is configured, absolute string targets (starting with `/`) are +automatically prefixed with the basePath. You only need to include the basePath +yourself when passing a `URL` object. + +```ts +// With basePath: "/app", this rewrites to /app/new +app.use((ctx) => ctx.rewrite("/new")); + +// URL targets must include the full path with basePath +app.use((ctx) => ctx.rewrite(new URL("/app/new", ctx.url))); +``` + +## Body ownership + +Calling `ctx.rewrite()` transfers the request body to the rewritten request. +Middleware cannot read `ctx.req` body after initiating a rewrite — attempting to +do so throws an error. + +## Loop prevention + +Fresh limits rewrites to 8 hops per request. Exceeding this limit throws a +`"Too many internal rewrites"` error. + +## Middleware use-cases + +Use `ctx.rewrite()` in a middleware to implement: + +- **i18n routing** — strip a locale prefix and dispatch to the locale-neutral + route: + + ```ts + const LOCALES = new Set(["de", "en", "fr"]); + + app.use((ctx) => { + const [, first, ...rest] = ctx.url.pathname.split("/"); + if (LOCALES.has(first)) { + ctx.state.locale = first; + return ctx.rewrite(`/${rest.join("/")}`); + } + return ctx.next(); + }); + ``` + +- **Strangler fig** — transparently forward legacy paths to their new + counterparts while keeping URLs stable for clients. + +- **Canonical routes** — consolidate several aliases into one handler without + issuing 301s. diff --git a/packages/fresh/src/app.ts b/packages/fresh/src/app.ts index c591007586b..94fc7925ec6 100644 --- a/packages/fresh/src/app.ts +++ b/packages/fresh/src/app.ts @@ -38,6 +38,63 @@ export const DEFAULT_CONN_INFO: any = { remoteAddr: { transport: "tcp", hostname: "localhost", port: 1234 }, }; +const MAX_REWRITE_COUNT = 8; + +function normalizeRequestUrl(req: Request, trustProxy: boolean): URL { + const url = new URL(req.url); + // Prevent open redirect attacks. + url.pathname = url.pathname.replace(/\/+/g, "/"); + + // Apply X-Forwarded-* headers when behind a reverse proxy. + if (trustProxy) { + const proto = req.headers.get("x-forwarded-proto"); + if (proto) { + url.protocol = proto + ":"; + } + const host = req.headers.get("x-forwarded-host"); + if (host) { + url.host = host; + } + } + + return url; +} + +function getRewriteUrl( + currentUrl: URL, + pathOrUrl: string | URL, + basePath: string, +): URL { + // Auto-prefix basePath for absolute string paths so that apps with a + // basePath don't silently drop it when calling ctx.rewrite("/some/path"). + let target: string | URL = pathOrUrl; + if ( + basePath !== "" && + typeof pathOrUrl === "string" && + pathOrUrl.startsWith("/") && + !pathOrUrl.startsWith(basePath) + ) { + target = basePath + pathOrUrl; + } + + const rewritten = target instanceof URL + ? new URL(target) + : new URL(target, currentUrl); + + if (rewritten.origin !== currentUrl.origin) { + throw new Error( + `ctx.rewrite() only supports same-origin URLs. Expected "${currentUrl.origin}", got "${rewritten.origin}"`, + ); + } + + // Keep existing query params unless the target explicitly sets a query. + if (typeof pathOrUrl === "string" && !pathOrUrl.includes("?")) { + rewritten.search = currentUrl.search; + } + + return rewritten; +} + const defaultOptionsHandler = (methods: string[]): () => Promise => { return () => Promise.resolve( @@ -416,27 +473,15 @@ export class App { ); const trustProxy = this.config.trustProxy; + const basePath = this.config.basePath; - return async ( + const dispatch = async ( req: Request, - conn: Deno.ServeHandlerInfo = DEFAULT_CONN_INFO, - ) => { - const url = new URL(req.url); - // Prevent open redirect attacks - url.pathname = url.pathname.replace(/\/+/g, "/"); - - // Apply X-Forwarded-* headers when behind a reverse proxy - if (trustProxy) { - const proto = req.headers.get("x-forwarded-proto"); - if (proto) { - url.protocol = proto + ":"; - } - const host = req.headers.get("x-forwarded-host"); - if (host) { - url.host = host; - } - } - + conn: Deno.ServeHandlerInfo, + state: State, + rewriteCount: number, + ): Promise => { + const url = normalizeRequestUrl(req, trustProxy); const method = req.method.toUpperCase() as Method; const matched = router.match(method, url); let { params, pattern, item: handler, methodMatch } = matched; @@ -473,6 +518,31 @@ export class App { this.config, next, buildCache!, + state, + (pathOrUrl) => { + if (rewriteCount >= MAX_REWRITE_COUNT) { + throw new Error( + `Too many internal rewrites while handling "${req.method} ${url.pathname}"`, + ); + } + + if (req.bodyUsed) { + throw new Error( + "Cannot rewrite request after its body has already been consumed", + ); + } + + const rewrittenUrl = getRewriteUrl(url, pathOrUrl, basePath); + const rewrittenReq = req.body !== null + ? new Request(rewrittenUrl, { + body: req.body, + method: req.method, + headers: req.headers, + duplex: "half", + } as RequestInit) + : new Request(rewrittenUrl, req); + return dispatch(rewrittenReq, conn, state, rewriteCount + 1); + }, ); try { @@ -493,6 +563,11 @@ export class App { return await DEFAULT_ERROR_HANDLER(ctx); } }; + + return ( + req: Request, + conn: Deno.ServeHandlerInfo = DEFAULT_CONN_INFO, + ) => dispatch(req, conn, {} as State, 0); } /** diff --git a/packages/fresh/src/app_test.tsx b/packages/fresh/src/app_test.tsx index c872a83f12e..e50b35968a6 100644 --- a/packages/fresh/src/app_test.tsx +++ b/packages/fresh/src/app_test.tsx @@ -262,6 +262,217 @@ Deno.test("App - methods with middleware", async () => { expect(await res.text()).toEqual("A"); }); +Deno.test("App - ctx.rewrite() rematches and preserves state", async () => { + const LOCALES = new Set(["de", "ru"]); + + const app = new App<{ locale?: string }>() + .use((ctx) => { + const [, first, ...rest] = ctx.url.pathname.split("/"); + + if (ctx.state.locale === undefined && LOCALES.has(first)) { + ctx.state.locale = first; + } + + if (LOCALES.has(first)) { + const rewritten = `/${rest.join("/")}`; + return ctx.rewrite(rewritten); + } + + if (ctx.state.locale === undefined) { + ctx.state.locale = "en"; + } + + return ctx.next(); + }) + .get("/hello", (ctx) => { + const q = ctx.url.searchParams.get("q") ?? ""; + return new Response( + `${ctx.state.locale}:${ctx.route}:${ctx.url.pathname}:${q}`, + ); + }); + + const server = new FakeServer(app.handler()); + + let res = await server.get("/de/hello?q=1"); + expect(await res.text()).toEqual("de:/hello:/hello:1"); + + res = await server.get("/hello?q=2"); + expect(await res.text()).toEqual("en:/hello:/hello:2"); +}); + +Deno.test("App - ctx.rewrite() from route middleware", async () => { + const app = new App() + .get("/legacy", (ctx) => ctx.rewrite("/modern")) + .get("/modern", () => new Response("ok")); + + const server = new FakeServer(app.handler()); + const res = await server.get("/legacy"); + expect(await res.text()).toEqual("ok"); +}); + +Deno.test("App - ctx.rewrite() allows post-processing in middleware", async () => { + const app = new App() + .use(async (ctx) => { + if (ctx.url.pathname === "/legacy") { + const res = await ctx.rewrite("/modern"); + res.headers.set("x-rewritten", "1"); + return res; + } + + return ctx.next(); + }) + .get("/modern", () => new Response("ok")); + + const server = new FakeServer(app.handler()); + const res = await server.get("/legacy"); + expect(await res.text()).toEqual("ok"); + expect(res.headers.get("x-rewritten")).toEqual("1"); +}); + +Deno.test("App - ctx.rewrite() preserves method and body", async () => { + const app = new App() + .use((ctx) => { + if (ctx.url.pathname === "/old") { + return ctx.rewrite("/new"); + } + return ctx.next(); + }) + .post("/new", async (ctx) => { + return new Response(`${ctx.req.method}:${await ctx.req.text()}`); + }); + + const server = new FakeServer(app.handler()); + const res = await server.post("/old", "payload"); + expect(await res.text()).toEqual("POST:payload"); +}); + +Deno.test("App - ctx.rewrite() preserves query by default", async () => { + const app = new App() + .get("/from", (ctx) => ctx.rewrite("/to")) + .get("/to", (ctx) => new Response(ctx.url.searchParams.get("q") ?? "")); + + const server = new FakeServer(app.handler()); + const res = await server.get("/from?q=123"); + expect(await res.text()).toEqual("123"); +}); + +Deno.test( + "App - ctx.rewrite() query in target overrides current query", + async () => { + const app = new App() + .get("/from", (ctx) => ctx.rewrite("/to?q=override")) + .get( + "/to", + (ctx) => new Response(ctx.url.searchParams.get("q") ?? ""), + ); + + const server = new FakeServer(app.handler()); + const res = await server.get("/from?q=123"); + expect(await res.text()).toEqual("override"); + }, +); + +Deno.test("App - ctx.rewrite() supports URL targets with basePath", async () => { + const app = new App({ basePath: "/base" }) + .get("/old", (ctx) => ctx.rewrite(new URL("/base/new?q=1", ctx.url))) + .get( + "/new", + (ctx) => + new Response( + `${ctx.url.pathname}:${ctx.url.searchParams.get("q") ?? ""}`, + ), + ); + + const server = new FakeServer(app.handler()); + const res = await server.get("/base/old"); + expect(await res.text()).toEqual("/base/new:1"); +}); + +Deno.test("App - ctx.rewrite() repopulates ctx.params for new route", async () => { + const app = new App() + .use((ctx) => { + if (ctx.url.pathname.startsWith("/u/")) { + return ctx.rewrite(ctx.url.pathname.replace("/u/", "/users/")); + } + return ctx.next(); + }) + .get("/users/:id", (ctx) => new Response(ctx.params.id)); + + const server = new FakeServer(app.handler()); + const res = await server.get("/u/123"); + expect(await res.text()).toEqual("123"); +}); + +Deno.test("App - ctx.rewrite() throws on rewrite loops", async () => { + const app = new App() + .use(async (ctx) => { + try { + return await ctx.next(); + } catch (err) { + return new Response(String(err), { status: 500 }); + } + }) + .use((ctx) => { + if (ctx.url.pathname === "/a") { + return ctx.rewrite("/b"); + } + if (ctx.url.pathname === "/b") { + return ctx.rewrite("/a"); + } + return ctx.next(); + }) + .get("/a", () => new Response("a")) + .get("/b", () => new Response("b")); + + const server = new FakeServer(app.handler()); + const res = await server.get("/a"); + + expect(res.status).toEqual(500); + expect(await res.text()).toContain("Too many internal rewrites"); +}); + +Deno.test("App - ctx.rewrite() rejects cross-origin targets", async () => { + const app = new App() + .use(async (ctx) => { + try { + return await ctx.next(); + } catch (err) { + return new Response(String(err), { status: 500 }); + } + }) + .get("/", (ctx) => ctx.rewrite("https://deno.land/")); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + + expect(res.status).toEqual(500); + expect(await res.text()).toContain("only supports same-origin URLs"); +}); + +Deno.test("App - ctx.rewrite() rejects rewrites after body consumption", async () => { + const app = new App() + .use(async (ctx) => { + try { + return await ctx.next(); + } catch (err) { + return new Response(String(err), { status: 500 }); + } + }) + .post("/", async (ctx) => { + await ctx.req.text(); + return ctx.rewrite("/next"); + }) + .post("/next", () => new Response("ok")); + + const server = new FakeServer(app.handler()); + const res = await server.post("/", "payload"); + + expect(res.status).toEqual(500); + expect(await res.text()).toContain( + "request after its body has already been consumed", + ); +}); + Deno.test("App - .mountApp() compose apps", async () => { const innerApp = new App<{ text: string }>() .use((ctx) => { diff --git a/packages/fresh/src/context.ts b/packages/fresh/src/context.ts index 8ac928aa11c..73d7f57e545 100644 --- a/packages/fresh/src/context.ts +++ b/packages/fresh/src/context.ts @@ -139,6 +139,14 @@ export let setAdditionalStyles: ( css: string[] | null | undefined, ) => void; +type RewriteHandler = (pathOrUrl: string | URL) => Promise; + +const DEFAULT_REWRITE: RewriteHandler = () => { + return Promise.reject( + new Error("ctx.rewrite() can only be called while handling a request"), + ); +}; + /** * The context passed to every middleware. It is unique for every request. */ @@ -162,7 +170,7 @@ export class Context { /** The url parameters of the matched route pattern. */ readonly params: Record; /** State object that is shared with all middlewares. */ - readonly state: State = {} as State; + readonly state: State; data: unknown = undefined; /** Error value if an error was caught (Default: null) */ error: unknown | null = null; @@ -203,6 +211,7 @@ export class Context { #buildCache: BuildCache; #additionalStyles: string[] | null = null; + #rewrite: RewriteHandler; Component!: FunctionComponent; @@ -238,16 +247,51 @@ export class Context { config: ResolvedFreshConfig, next: () => Promise, buildCache: BuildCache, + state: State = {} as State, + rewrite: RewriteHandler = DEFAULT_REWRITE, ) { this.url = url; this.req = req; this.info = info; this.params = params; this.route = route; + this.state = state; this.config = config; this.isPartial = url.searchParams.has(PARTIAL_SEARCH_PARAM); this.next = next; this.#buildCache = buildCache; + this.#rewrite = rewrite; + } + + /** + * Rewrite the current request to another path and continue handling + * it internally without redirecting the client. The browser URL stays the + * same; Fresh rematches and dispatches the rewritten path instead. + * + * - Only same-origin targets are accepted — passing a cross-origin URL + * throws an error. + * - If the target is a string without a query part, the current query + * parameters are preserved. + * - When `basePath` is configured, absolute string targets (starting with + * `/`) are automatically prefixed with the basePath. + * - **Body ownership:** `ctx.rewrite()` transfers the request body to the + * rewritten request. The calling middleware can no longer read `ctx.req` + * body after the call. + * + * ```ts + * app.use((ctx) => { + * if (ctx.url.pathname.startsWith("/legacy/")) { + * return ctx.rewrite(ctx.url.pathname.replace("/legacy", "")); + * } + * + * return ctx.next(); + * }); + * ``` + * + * @see {@link redirect} for sending an HTTP redirect to the client instead. + */ + rewrite(pathOrUrl: string | URL): Promise { + return this.#rewrite(pathOrUrl); } /**