Skip to content

feat: Make URL rewriting possible#3812

Open
maurictg wants to merge 6 commits into
freshframework:mainfrom
maurictg:feat/rewrite
Open

feat: Make URL rewriting possible#3812
maurictg wants to merge 6 commits into
freshframework:mainfrom
maurictg:feat/rewrite

Conversation

@maurictg
Copy link
Copy Markdown

@maurictg maurictg commented May 22, 2026

Currently there is no way built-in way to rewrite routes in Fresh. Other frameworks like SvelteKit and Astro do offer this feature.

There are multiple reasons why rewriting routes is a desirable feature:

  • Localized routes (i18n)
  • Strangler fig pattern
  • Canonical routes

I know there is currently one way to achieve rewriting without modifying Fresh' source code:

import fetcher from './_fresh/server.js';

Deno.serve((req, info) => {
    const url = new URL(req.url);
    // your rewrite logic...

    return fetcher.fetch(new Request(url, req), info);
});

However, there are a few disadvantages to this strategy:

  • Outside Fresh
  • Not working with Vite
  • Not possible to add data to the context/state (for instance the language)

All suggestions are welcome! To be honest: this is my first time ever contributing to Deno and/or Fresh, and there might be better approaches. But at least this PR serves as proof it is technically possible to have rewrite middleware.

@maurictg maurictg changed the title Add rewrite functionality feat:Rewrite functionality May 22, 2026
@maurictg maurictg changed the title feat:Rewrite functionality feat: Rewrite functionality May 22, 2026
@maurictg maurictg marked this pull request as draft May 22, 2026 13:10
@maurictg maurictg marked this pull request as ready for review May 22, 2026 16:06
@maurictg maurictg changed the title feat: Rewrite functionality feat: Make URL rewriting possible May 22, 2026
Copy link
Copy Markdown
Contributor

@bartlomieju bartlomieju left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice feature, and well-scoped for a first contribution. The security-relevant pieces (same-origin enforcement, path normalization on every dispatch, loop cap, body-used guard) all look right, and the test coverage is thorough.

A few things to consider, mostly polish — see inline comments. The two that matter most:

  1. Behavior of string targets when basePath is configured — currently new URL("/new", ctx.url) will drop the basePath segment, so ctx.rewrite("/new") won't match routes registered under the basePath. The basePath test sidesteps this by passing a full URL. Worth deciding whether string targets should be auto-prefixed with basePath, or documenting that they must include it.
  2. The docs are added under docs/latest/concepts/, which is the Fresh 1.x docs surface. The Fresh 2 canary docs live under docs/canary/the-canary-version/. Worth confirming with maintainers where this should live.

Minor nice-to-have: a test that asserts ctx.params is repopulated against the new route after a rewrite (e.g. rewrite /u/123/users/:id and read ctx.params.id in the target). The current tests cover route and url, but not params.

Comment thread packages/fresh/src/app.ts Outdated
function getRewriteUrl(currentUrl: URL, pathOrUrl: string | URL): URL {
const rewritten = pathOrUrl instanceof URL
? new URL(pathOrUrl)
: new URL(pathOrUrl, currentUrl);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

basePath footgun: this resolves the string target against currentUrl, which means ctx.rewrite("/new") in an app with basePath: "/base" produces a URL of /new (not /base/new) and won't match any registered routes. The basePath test on line 348 works around this by passing a full URL object.

Two reasonable options:

  • Auto-prefix this.config.basePath for string targets that don't already start with it (mirrors how route registration works).
  • Document explicitly that string targets must be absolute paths including the basePath, and only URL targets bypass that.

Either is fine, but the current behavior is going to surprise users with a non-empty basePath.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with the auto-prefixing to keep it consistent with the route registration

Comment thread packages/fresh/src/app.ts Outdated
}

const rewrittenUrl = getRewriteUrl(url, pathOrUrl);
const rewrittenReq = new Request(rewrittenUrl, req);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small notes on new Request(rewrittenUrl, req):

  1. This transfers ownership of the body stream from req to the rewritten request. Any middleware that planned to read ctx.req after calling ctx.rewrite() will see an already-consumed body. Worth a one-liner in the JSDoc on Context.rewrite.
  2. For streamed request bodies, some runtimes require { duplex: "half" } when constructing a Request from a body-bearing init. Deno's current stable runtime appears to tolerate this, but it would be safer to set duplex: "half" explicitly when req.body !== null.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. True, I'll add that
  2. I'll change it to half when a request body is present, but I am not sure if Deno supports this fully. I assume so. The duplex property is by the way not in de VScode built in types (lib.dom.d.ts) but it is in the MDN docs.

Comment thread packages/fresh/src/app.ts Outdated
remoteAddr: { transport: "tcp", hostname: "localhost", port: 1234 },
};

const MAX_REWRITE_COUNT = 16;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

16 feels generous for an internal-rewrite cap — even legitimate rewrite chains rarely go past 2–3 hops. Not a blocker, but 8 would catch buggy middleware sooner with fewer wasted Request/URL allocations. Up to you.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right. Lets go with 8.

* });
* ```
*/
rewrite(pathOrUrl: string | URL): Promise<Response> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth folding two facts from the prose docs into the JSDoc so they show up in editor tooltips:

  • Only same-origin targets are accepted (throws otherwise).
  • The rewrite transfers ownership of the request body — ctx.req won't be readable afterwards in the calling middleware.

Also consider an @see cross-link to redirect() since they're conceptually paired.

Comment thread packages/fresh/src/app_test.tsx Outdated

if (LOCALES.has(first)) {
const rewritten = `/${rest.join("/")}`;
return ctx.rewrite(rewritten === "/" ? "/" : rewritten);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: rewritten === "/" ? "/" : rewritten is a no-op — both branches return the same value. Can just be ctx.rewrite(rewritten).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, it was a hot afternoon

Comment thread docs/latest/concepts/context.md Outdated
});
```

## `.rewrite()`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs/latest/ is the Fresh 1.x docs surface. Fresh 2's docs live under docs/canary/the-canary-version/, which (at the moment) doesn't have context.md / middleware.md equivalents. Worth confirming with maintainers whether this documentation should be mirrored/moved there — otherwise users on Fresh 2 won't find these docs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allright, I'll create a file there and one might decide to move it to the right place.
But.. Fresh 2 isn't canary anymore right?

@maurictg
Copy link
Copy Markdown
Author

@bartlomieju Are you willing to review again? I processed your feedback!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants