Skip to content

Commit f5f8928

Browse files
authored
feat(server): trailing slash configuration (#1291)
1 parent 8030656 commit f5f8928

File tree

8 files changed

+106
-1
lines changed

8 files changed

+106
-1
lines changed

src/server/context.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
RenderOptions,
3434
Route,
3535
RouteModule,
36+
RouterOptions,
3637
UnknownPage,
3738
UnknownPageModule,
3839
} from "./types.ts";
@@ -86,6 +87,7 @@ export class ServerContext {
8687
#error: ErrorPage;
8788
#plugins: Plugin[];
8889
#builder: Builder | Promise<BuildSnapshot> | BuildSnapshot;
90+
#routerOptions: RouterOptions;
8991

9092
constructor(
9193
routes: Route[],
@@ -100,6 +102,7 @@ export class ServerContext {
100102
configPath: string,
101103
jsxConfig: JSXConfig,
102104
dev: boolean = isDevMode(),
105+
routerOptions: RouterOptions,
103106
) {
104107
this.#routes = routes;
105108
this.#islands = islands;
@@ -118,6 +121,7 @@ export class ServerContext {
118121
dev: this.#dev,
119122
jsxConfig,
120123
});
124+
this.#routerOptions = routerOptions;
121125
}
122126

123127
/**
@@ -345,6 +349,7 @@ export class ServerContext {
345349
configPath,
346350
jsxConfig,
347351
dev,
352+
opts.router ?? DEFAULT_ROUTER_OPTIONS,
348353
);
349354
}
350355

@@ -359,19 +364,25 @@ export class ServerContext {
359364
this.#middlewares,
360365
handlers.errorHandler,
361366
);
367+
const trailingSlashEnabled = this.#routerOptions?.trailingSlash;
362368
return async function handler(req: Request, connInfo: ConnInfo) {
363369
// Redirect requests that end with a trailing slash to their non-trailing
364370
// slash counterpart.
365371
// Ex: /about/ -> /about
366372
const url = new URL(req.url);
367-
if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
373+
if (
374+
url.pathname.length > 1 && url.pathname.endsWith("/") &&
375+
!trailingSlashEnabled
376+
) {
368377
// Remove trailing slashes
369378
const path = url.pathname.replace(/\/+$/, "");
370379
const location = `${path}${url.search}`;
371380
return new Response(null, {
372381
status: Status.TemporaryRedirect,
373382
headers: { location },
374383
});
384+
} else if (trailingSlashEnabled && !url.pathname.endsWith("/")) {
385+
return Response.redirect(url.href + "/", Status.PermanentRedirect);
375386
}
376387

377388
return await withMiddlewares(req, connInfo, inner);
@@ -606,6 +617,9 @@ export class ServerContext {
606617
const createUnknownRender = genRender(this.#notFound, Status.NotFound);
607618

608619
for (const route of this.#routes) {
620+
if (this.#routerOptions.trailingSlash && route.pattern != "/") {
621+
route.pattern += "/";
622+
}
609623
const createRender = genRender(route, Status.OK);
610624
if (typeof route.handler === "function") {
611625
routes[route.pattern] = {
@@ -778,6 +792,10 @@ const DEFAULT_RENDER_FN: RenderFunction = (_ctx, render) => {
778792
render();
779793
};
780794

795+
const DEFAULT_ROUTER_OPTIONS: RouterOptions = {
796+
trailingSlash: false,
797+
};
798+
781799
const DEFAULT_APP: AppModule = {
782800
default: ({ Component }) => h(Component, {}),
783801
};

src/server/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ export interface FreshOptions {
1919
render?: RenderFunction;
2020
plugins?: Plugin[];
2121
staticDir?: string;
22+
router?: RouterOptions;
23+
}
24+
25+
export interface RouterOptions {
26+
/**
27+
* Controls whether Fresh will append a trailing slash to the URL.
28+
* @default {false}
29+
*/
30+
trailingSlash?: boolean;
2231
}
2332

2433
export type RenderFunction = (

tests/fixture_router/dev.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env -S deno run -A --watch=static/,routes/
2+
3+
import dev from "$fresh/dev.ts";
4+
5+
await dev(import.meta.url, "./main.ts");

tests/fixture_router/fresh.gen.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// DO NOT EDIT. This file is generated by fresh.
2+
// This file SHOULD be checked into source version control.
3+
// This file is automatically updated during development when running `dev.ts`.
4+
5+
import * as $0 from "./routes/about.tsx";
6+
import * as $1 from "./routes/index.tsx";
7+
8+
const manifest = {
9+
routes: {
10+
"./routes/about.tsx": $0,
11+
"./routes/index.tsx": $1,
12+
},
13+
islands: {},
14+
baseUrl: import.meta.url,
15+
};
16+
17+
export default manifest;

tests/fixture_router/main.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/// <reference no-default-lib="true" />
2+
/// <reference lib="dom" />
3+
/// <reference lib="dom.asynciterable" />
4+
/// <reference lib="deno.ns" />
5+
/// <reference lib="deno.unstable" />
6+
7+
import { start } from "$fresh/server.ts";
8+
import manifest from "./fresh.gen.ts";
9+
10+
await start(manifest);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function About() {
2+
return (
3+
<div>
4+
About
5+
</div>
6+
);
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Home() {
2+
return (
3+
<div>
4+
Hello
5+
</div>
6+
);
7+
}

tests/trailing_slash_test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ServerContext, Status } from "../server.ts";
2+
import { assert, assertEquals } from "./deps.ts";
3+
import manifest from "./fixture_router/fresh.gen.ts";
4+
5+
const ctx = await ServerContext.fromManifest(manifest, {
6+
router: {
7+
trailingSlash: true,
8+
},
9+
});
10+
const router = (req: Request) => {
11+
return ctx.handler()(req, {
12+
localAddr: {
13+
transport: "tcp",
14+
hostname: "127.0.0.1",
15+
port: 80,
16+
},
17+
remoteAddr: {
18+
transport: "tcp",
19+
hostname: "127.0.0.1",
20+
port: 80,
21+
},
22+
});
23+
};
24+
25+
Deno.test("forwards slash placed at the end of url", async () => {
26+
const targetUrl = "https://fresh.deno.dev/about";
27+
const resp = await router(new Request(targetUrl));
28+
assert(resp);
29+
assertEquals(resp.status, Status.PermanentRedirect);
30+
// forwarded location should be with trailing slash
31+
assertEquals(resp.headers.get("location"), targetUrl + "/");
32+
});

0 commit comments

Comments
 (0)