Skip to content

Commit f382217

Browse files
committed
Add rewrite functionality
1 parent 39b5f06 commit f382217

5 files changed

Lines changed: 338 additions & 20 deletions

File tree

docs/latest/concepts/context.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,28 @@ app.get("/old-url", (ctx) => {
130130
});
131131
```
132132

133+
## `.rewrite()`
134+
135+
Rewrite a request internally to another route without redirecting the client.
136+
The browser URL stays the same, but Fresh rematches and handles the rewritten
137+
path.
138+
139+
```ts
140+
app.use((ctx) => {
141+
if (ctx.url.pathname.startsWith("/legacy/")) {
142+
const pathname = ctx.url.pathname.replace("/legacy", "");
143+
return ctx.rewrite(pathname);
144+
}
145+
146+
return ctx.next();
147+
});
148+
```
149+
150+
`ctx.rewrite()` only accepts same-origin targets.
151+
152+
When the target is a string without a `?query`, Fresh keeps the current query
153+
parameters.
154+
133155
## `.render()`
134156

135157
Render JSX and create a HTML `Response`.

docs/latest/concepts/middleware.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ const app = new App<{ greeting: string }>()
3434
Middlewares can be chained and combined in whatever way you desire. They are an
3535
excellent way to make http-related logic reusable on the server.
3636

37+
## Internal rewrites
38+
39+
Use `ctx.rewrite()` when you want to resolve a different route without sending
40+
an HTTP redirect to the browser:
41+
42+
```ts
43+
app.use((ctx) => {
44+
if (ctx.url.pathname === "/docs/latest") {
45+
return ctx.rewrite("/docs");
46+
}
47+
48+
return ctx.next();
49+
});
50+
```
51+
3752
## Middleware helper
3853

3954
Use the `define.middleware()` helper to get typings out of the box:

packages/fresh/src/app.ts

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,47 @@ export const DEFAULT_CONN_INFO: any = {
3838
remoteAddr: { transport: "tcp", hostname: "localhost", port: 1234 },
3939
};
4040

41+
const MAX_REWRITE_COUNT = 16;
42+
43+
function normalizeRequestUrl(req: Request, trustProxy: boolean): URL {
44+
const url = new URL(req.url);
45+
// Prevent open redirect attacks.
46+
url.pathname = url.pathname.replace(/\/+/g, "/");
47+
48+
// Apply X-Forwarded-* headers when behind a reverse proxy.
49+
if (trustProxy) {
50+
const proto = req.headers.get("x-forwarded-proto");
51+
if (proto) {
52+
url.protocol = proto + ":";
53+
}
54+
const host = req.headers.get("x-forwarded-host");
55+
if (host) {
56+
url.host = host;
57+
}
58+
}
59+
60+
return url;
61+
}
62+
63+
function getRewriteUrl(currentUrl: URL, pathOrUrl: string | URL): URL {
64+
const rewritten = pathOrUrl instanceof URL
65+
? new URL(pathOrUrl)
66+
: new URL(pathOrUrl, currentUrl);
67+
68+
if (rewritten.origin !== currentUrl.origin) {
69+
throw new Error(
70+
`ctx.rewrite() only supports same-origin URLs. Expected "${currentUrl.origin}", got "${rewritten.origin}"`,
71+
);
72+
}
73+
74+
// Keep existing query params unless the target explicitly sets a query.
75+
if (typeof pathOrUrl === "string" && !pathOrUrl.includes("?")) {
76+
rewritten.search = currentUrl.search;
77+
}
78+
79+
return rewritten;
80+
}
81+
4182
const defaultOptionsHandler = (methods: string[]): () => Promise<Response> => {
4283
return () =>
4384
Promise.resolve(
@@ -417,26 +458,13 @@ export class App<State> {
417458

418459
const trustProxy = this.config.trustProxy;
419460

420-
return async (
461+
const dispatch = async (
421462
req: Request,
422-
conn: Deno.ServeHandlerInfo = DEFAULT_CONN_INFO,
423-
) => {
424-
const url = new URL(req.url);
425-
// Prevent open redirect attacks
426-
url.pathname = url.pathname.replace(/\/+/g, "/");
427-
428-
// Apply X-Forwarded-* headers when behind a reverse proxy
429-
if (trustProxy) {
430-
const proto = req.headers.get("x-forwarded-proto");
431-
if (proto) {
432-
url.protocol = proto + ":";
433-
}
434-
const host = req.headers.get("x-forwarded-host");
435-
if (host) {
436-
url.host = host;
437-
}
438-
}
439-
463+
conn: Deno.ServeHandlerInfo,
464+
state: State,
465+
rewriteCount: number,
466+
): Promise<Response> => {
467+
const url = normalizeRequestUrl(req, trustProxy);
440468
const method = req.method.toUpperCase() as Method;
441469
const matched = router.match(method, url);
442470
let { params, pattern, item: handler, methodMatch } = matched;
@@ -473,6 +501,24 @@ export class App<State> {
473501
this.config,
474502
next,
475503
buildCache!,
504+
state,
505+
(pathOrUrl) => {
506+
if (rewriteCount >= MAX_REWRITE_COUNT) {
507+
throw new Error(
508+
`Too many internal rewrites while handling "${req.method} ${url.pathname}"`,
509+
);
510+
}
511+
512+
if (req.bodyUsed) {
513+
throw new Error(
514+
"Cannot rewrite request after its body has already been consumed",
515+
);
516+
}
517+
518+
const rewrittenUrl = getRewriteUrl(url, pathOrUrl);
519+
const rewrittenReq = new Request(rewrittenUrl, req);
520+
return dispatch(rewrittenReq, conn, state, rewriteCount + 1);
521+
},
476522
);
477523

478524
try {
@@ -493,6 +539,11 @@ export class App<State> {
493539
return await DEFAULT_ERROR_HANDLER(ctx);
494540
}
495541
};
542+
543+
return (
544+
req: Request,
545+
conn: Deno.ServeHandlerInfo = DEFAULT_CONN_INFO,
546+
) => dispatch(req, conn, {} as State, 0);
496547
}
497548

498549
/**

packages/fresh/src/app_test.tsx

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,202 @@ Deno.test("App - methods with middleware", async () => {
235235
expect(await res.text()).toEqual("A");
236236
});
237237

238+
Deno.test("App - ctx.rewrite() rematches and preserves state", async () => {
239+
const LOCALES = new Set(["de", "ru"]);
240+
241+
const app = new App<{ locale?: string }>()
242+
.use((ctx) => {
243+
const [, first, ...rest] = ctx.url.pathname.split("/");
244+
245+
if (ctx.state.locale === undefined && LOCALES.has(first)) {
246+
ctx.state.locale = first;
247+
}
248+
249+
if (LOCALES.has(first)) {
250+
const rewritten = `/${rest.join("/")}`;
251+
return ctx.rewrite(rewritten === "/" ? "/" : rewritten);
252+
}
253+
254+
if (ctx.state.locale === undefined) {
255+
ctx.state.locale = "en";
256+
}
257+
258+
return ctx.next();
259+
})
260+
.get("/hello", (ctx) => {
261+
const q = ctx.url.searchParams.get("q") ?? "";
262+
return new Response(
263+
`${ctx.state.locale}:${ctx.route}:${ctx.url.pathname}:${q}`,
264+
);
265+
});
266+
267+
const server = new FakeServer(app.handler());
268+
269+
let res = await server.get("/de/hello?q=1");
270+
expect(await res.text()).toEqual("de:/hello:/hello:1");
271+
272+
res = await server.get("/hello?q=2");
273+
expect(await res.text()).toEqual("en:/hello:/hello:2");
274+
});
275+
276+
Deno.test("App - ctx.rewrite() from route middleware", async () => {
277+
const app = new App()
278+
.get("/legacy", (ctx) => ctx.rewrite("/modern"))
279+
.get("/modern", () => new Response("ok"));
280+
281+
const server = new FakeServer(app.handler());
282+
const res = await server.get("/legacy");
283+
expect(await res.text()).toEqual("ok");
284+
});
285+
286+
Deno.test("App - ctx.rewrite() allows post-processing in middleware", async () => {
287+
const app = new App()
288+
.use(async (ctx) => {
289+
if (ctx.url.pathname === "/legacy") {
290+
const res = await ctx.rewrite("/modern");
291+
res.headers.set("x-rewritten", "1");
292+
return res;
293+
}
294+
295+
return ctx.next();
296+
})
297+
.get("/modern", () => new Response("ok"));
298+
299+
const server = new FakeServer(app.handler());
300+
const res = await server.get("/legacy");
301+
expect(await res.text()).toEqual("ok");
302+
expect(res.headers.get("x-rewritten")).toEqual("1");
303+
});
304+
305+
Deno.test("App - ctx.rewrite() preserves method and body", async () => {
306+
const app = new App()
307+
.use((ctx) => {
308+
if (ctx.url.pathname === "/old") {
309+
return ctx.rewrite("/new");
310+
}
311+
return ctx.next();
312+
})
313+
.post("/new", async (ctx) => {
314+
return new Response(`${ctx.req.method}:${await ctx.req.text()}`);
315+
});
316+
317+
const server = new FakeServer(app.handler());
318+
const res = await server.post("/old", "payload");
319+
expect(await res.text()).toEqual("POST:payload");
320+
});
321+
322+
Deno.test("App - ctx.rewrite() preserves query by default", async () => {
323+
const app = new App()
324+
.get("/from", (ctx) => ctx.rewrite("/to"))
325+
.get("/to", (ctx) => new Response(ctx.url.searchParams.get("q") ?? ""));
326+
327+
const server = new FakeServer(app.handler());
328+
const res = await server.get("/from?q=123");
329+
expect(await res.text()).toEqual("123");
330+
});
331+
332+
Deno.test(
333+
"App - ctx.rewrite() query in target overrides current query",
334+
async () => {
335+
const app = new App()
336+
.get("/from", (ctx) => ctx.rewrite("/to?q=override"))
337+
.get(
338+
"/to",
339+
(ctx) => new Response(ctx.url.searchParams.get("q") ?? ""),
340+
);
341+
342+
const server = new FakeServer(app.handler());
343+
const res = await server.get("/from?q=123");
344+
expect(await res.text()).toEqual("override");
345+
},
346+
);
347+
348+
Deno.test("App - ctx.rewrite() supports URL targets with basePath", async () => {
349+
const app = new App({ basePath: "/base" })
350+
.get("/old", (ctx) => ctx.rewrite(new URL("/base/new?q=1", ctx.url)))
351+
.get(
352+
"/new",
353+
(ctx) =>
354+
new Response(
355+
`${ctx.url.pathname}:${ctx.url.searchParams.get("q") ?? ""}`,
356+
),
357+
);
358+
359+
const server = new FakeServer(app.handler());
360+
const res = await server.get("/base/old");
361+
expect(await res.text()).toEqual("/base/new:1");
362+
});
363+
364+
Deno.test("App - ctx.rewrite() throws on rewrite loops", async () => {
365+
const app = new App()
366+
.use(async (ctx) => {
367+
try {
368+
return await ctx.next();
369+
} catch (err) {
370+
return new Response(String(err), { status: 500 });
371+
}
372+
})
373+
.use((ctx) => {
374+
if (ctx.url.pathname === "/a") {
375+
return ctx.rewrite("/b");
376+
}
377+
if (ctx.url.pathname === "/b") {
378+
return ctx.rewrite("/a");
379+
}
380+
return ctx.next();
381+
})
382+
.get("/a", () => new Response("a"))
383+
.get("/b", () => new Response("b"));
384+
385+
const server = new FakeServer(app.handler());
386+
const res = await server.get("/a");
387+
388+
expect(res.status).toEqual(500);
389+
expect(await res.text()).toContain("Too many internal rewrites");
390+
});
391+
392+
Deno.test("App - ctx.rewrite() rejects cross-origin targets", async () => {
393+
const app = new App()
394+
.use(async (ctx) => {
395+
try {
396+
return await ctx.next();
397+
} catch (err) {
398+
return new Response(String(err), { status: 500 });
399+
}
400+
})
401+
.get("/", (ctx) => ctx.rewrite("https://deno.land/"));
402+
403+
const server = new FakeServer(app.handler());
404+
const res = await server.get("/");
405+
406+
expect(res.status).toEqual(500);
407+
expect(await res.text()).toContain("only supports same-origin URLs");
408+
});
409+
410+
Deno.test("App - ctx.rewrite() rejects rewrites after body consumption", async () => {
411+
const app = new App()
412+
.use(async (ctx) => {
413+
try {
414+
return await ctx.next();
415+
} catch (err) {
416+
return new Response(String(err), { status: 500 });
417+
}
418+
})
419+
.post("/", async (ctx) => {
420+
await ctx.req.text();
421+
return ctx.rewrite("/next");
422+
})
423+
.post("/next", () => new Response("ok"));
424+
425+
const server = new FakeServer(app.handler());
426+
const res = await server.post("/", "payload");
427+
428+
expect(res.status).toEqual(500);
429+
expect(await res.text()).toContain(
430+
"request after its body has already been consumed",
431+
);
432+
});
433+
238434
Deno.test("App - .mountApp() compose apps", async () => {
239435
const innerApp = new App<{ text: string }>()
240436
.use((ctx) => {

0 commit comments

Comments
 (0)