Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/canary/the-canary-version/rewrites.md
Original file line number Diff line number Diff line change
@@ -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.
113 changes: 94 additions & 19 deletions packages/fresh/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> => {
return () =>
Promise.resolve(
Expand Down Expand Up @@ -416,27 +473,15 @@ export class App<State> {
);

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<Response> => {
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;
Expand Down Expand Up @@ -473,6 +518,31 @@ export class App<State> {
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 {
Expand All @@ -493,6 +563,11 @@ export class App<State> {
return await DEFAULT_ERROR_HANDLER(ctx);
}
};

return (
req: Request,
conn: Deno.ServeHandlerInfo = DEFAULT_CONN_INFO,
) => dispatch(req, conn, {} as State, 0);
}

/**
Expand Down
Loading