Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
22 changes: 22 additions & 0 deletions docs/latest/concepts/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ app.get("/old-url", (ctx) => {
});
```

## `.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?


Rewrite a request internally to another route without redirecting the client.
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();
});
```

`ctx.rewrite()` only accepts same-origin targets.

When the target is a string without a `?query`, Fresh keeps the current query
parameters.

## `.render()`

Render JSX and create a HTML `Response`.
Expand Down
15 changes: 15 additions & 0 deletions docs/latest/concepts/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ const app = new App<{ greeting: string }>()
Middlewares can be chained and combined in whatever way you desire. They are an
excellent way to make http-related logic reusable on the server.

## Internal rewrites

Use `ctx.rewrite()` when you want to resolve a different route without sending
an HTTP redirect to the browser:

```ts
app.use((ctx) => {
if (ctx.url.pathname === "/docs/latest") {
return ctx.rewrite("/docs");
}

return ctx.next();
});
```

## Middleware helper

Use the `define.middleware()` helper to get typings out of the box:
Expand Down
89 changes: 70 additions & 19 deletions packages/fresh/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,47 @@ export const DEFAULT_CONN_INFO: any = {
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.


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): 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


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 @@ -417,26 +458,13 @@ export class App<State> {

const trustProxy = this.config.trustProxy;

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 +501,24 @@ 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);
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.

return dispatch(rewrittenReq, conn, state, rewriteCount + 1);
},
);

try {
Expand All @@ -493,6 +539,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
196 changes: 196 additions & 0 deletions packages/fresh/src/app_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,202 @@ 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 === "/" ? "/" : 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

}

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() 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) => {
Expand Down
Loading
Loading