Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow trailing slash redirect to be disabled #2356

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
225 changes: 225 additions & 0 deletions docs/canary/concepts/server-configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
---
description: |
The ability to configure the core Fresh server leads to its flexibility.
---

In this page we discuss how the server can be configured during startup.

The signature of the primary method looks like this:

```ts main.ts
export async function start(manifest: Manifest, config: FreshConfig = {});
```

## Configuration

`Manifest` comes from `fresh.gen.ts`, so nothing to do there. `config` is where
things get interesting.
[`FreshConfig`](https://deno.land/x/fresh/server.ts?s=FreshConfig) looks like
this:

```ts
export interface FreshConfig {
build?: {
/**
* The directory to write generated files to when `dev.ts build` is run.
* This can be an absolute path, a file URL or a relative path.
*/
outDir?: string;
/**
* This sets the target environment for the generated code. Newer
* language constructs will be transformed to match the specified
* support range. See https://esbuild.github.io/api/#target
* @default {"es2022"}
*/
target?: string | string[];
};
render?: RenderFunction;
plugins?: Plugin[];
staticDir?: string;
router?: RouterOptions;
server?: Partial<Deno.ServeTlsOptions>;
}
```

And for completeness here are the remaining two types:

```ts
export type RenderFunction = (
ctx: RenderContext,
render: InnerRenderFunction,
) => void | Promise<void>;

export interface RouterOptions {
/**
* Controls whether Fresh will append a trailing slash to the URL.
* @default {false}
*/
trailingSlash?: boolean;
/**
* Configures the pattern of files to ignore in islands and routes.
*
* By default Fresh will ignore test files,
* for example files with a `.test.ts` or a `_test.ts` suffix.
*
* @default {/(?:[^/]*_|[^/]*\.|)test\.(?:ts|tsx|mts|js|mjs|jsx|)\/*$/}
*/
ignoreFilePattern?: RegExp;
/**
* Serve fresh from a base path instead of from the root.
* "/foo/bar" -> http://localhost:8000/foo/bar
* @default {undefined}
*/
basePath?: string;
/**
* Disables the trailing slash redirect.
* @default {false}
*/
disableTrailingSlashRedirect?: boolean;
}
```

## Build

### outDir

As the comment suggests, this can be used to configure where generated files are
written:

```tsx
await dev(import.meta.url, "./main.ts", {
build: {
outDir: Deno.env.get("FRESH_TEST_OUTDIR") ?? undefined,
},
});
```

### target

This should be a valid ES Build target.

```tsx
await dev(import.meta.url, "./main.ts", {
build: {
target: "es2015",
},
});
```

## Plugins

See the [docs](/docs/concepts/plugins) on this topic for more detail. But as a
quick example, you can do something like this to load plugins:

```ts main.ts
await start(manifest, { plugins: [twindPlugin(twindConfig)] });
```

## StaticDir

This allows you to specify the location where your site's static assets are
stored. Here's an example:

```ts main.ts
await start(manifest, { staticDir: "./custom_static" });
```

## Render

This is by far the most complicated option currently available. It allows you to
configure how your components get rendered.

## RouterOptions

### TrailingSlash

By default Fresh uses URLs like `https://www.example.com/about`. If you'd like,
you can configure this to `https://www.example.com/about/` by using the
`trailingSlash` setting.

```ts main.ts
await start(manifest, { router: { trailingSlash: true } });
```

### ignoreFilePattern

By default Fresh ignores test files which are co-located next routes and
islands. If you want, you can change the pattern Fresh uses ignore these files

### basePath

This setting allows you to serve a Fresh app from sub-path of a domain. A value
of `/foo/bar` would serve the app from `http://localhost:8000/foo/bar` instead
of `http://localhost:8000/` for example.

The `basePath` will be automatically applied to absolute links in your app. For
example, when the `basePath` is `/foo/bar`, linking to `/about` will
automatically become `/foo/bar/about`.

```jsx
<a href="/about">About</a>;
```

Rendered HTML:

```html
<a href="/foo/bar/about">About</a>
```

The `basePath` is also applied to the `src` and `srcset` attribute of
`<img>`-tags, the `href` attribute of `<link>` and the `src` attribute of
`<script>` tags.

### disableTrailingSlashRedirect

Completely disables the trailing slash redirect functionality. Requests to
`/about/` will no longer be redirected to `/about`. Note that the router
currently does not support defining routes for both `/about/` and `/about`. This
functionality is useful if you want to intercept requests to `/about/` yourself
and then respond appropriately.

## Server

Now that Deno has stabilized [Deno.serve](https://deno.land/api?s=Deno.serve)
and Fresh has switched to using this API, all server configuration options are
embedded in `server` inside the `FreshConfig`. The fully expanded set of
parameters looks like this:

```ts
server: {
/** Server private key in PEM format */
cert: string;

/** Cert chain in PEM format */
key: string;

/** The port to listen on.
*
* @default {8000} */
port?: number;

/** A literal IP address or host name that can be resolved to an IP address.
*
* __Note about `0.0.0.0`__ While listening `0.0.0.0` works on all platforms,
* the browsers on Windows don't work with the address `0.0.0.0`.
* You should show the message like `server running on localhost:8080` instead of
* `server running on 0.0.0.0:8080` if your program supports Windows.
*
* @default {"0.0.0.0"} */
hostname?: string;

/** An {@linkcode AbortSignal} to close the server and all connections. */
signal?: AbortSignal;

/** Sets `SO_REUSEPORT` on POSIX systems. */
reusePort?: boolean;

/** The handler to invoke when route handlers throw an error. */
onError?: (error: unknown) => Response | Promise<Response>;

/** The callback which is called when the server starts listening. */
onListen?: (params: { hostname: string; port: number }) => void;
}
```

Use these to configure your server as you see fit.
2 changes: 1 addition & 1 deletion docs/toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const toc: RawTableOfContents = {
["deployment", "Deployment", "link:latest"],
["plugins", "Plugins", "link:latest"],
["updating", "Updating Fresh", "link:latest"],
["server-configuration", "Server configuration", "link:latest"],
["server-configuration", "Server configuration", "link:canary"],
],
},
integrations: {
Expand Down
9 changes: 8 additions & 1 deletion src/server/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ export class ServerContext {
basePath,
);
const trailingSlashEnabled = this.#state.config.router?.trailingSlash;
const disableTrailingSlashRedirect =
this.#state.config.router?.disableTrailingSlashRedirect ?? false;
const isDev = this.#dev;

if (this.#dev) {
Expand Down Expand Up @@ -235,6 +237,7 @@ export class ServerContext {
// slash counterpart.
// Ex: /about/ -> /about
if (
!disableTrailingSlashRedirect &&
url.pathname.length > 1 && url.pathname.endsWith("/") &&
!trailingSlashEnabled
) {
Expand All @@ -245,7 +248,11 @@ export class ServerContext {
status: STATUS_CODE.TemporaryRedirect,
headers: { location },
});
} else if (trailingSlashEnabled && !url.pathname.endsWith("/")) {
} else if (
!disableTrailingSlashRedirect &&
trailingSlashEnabled &&
!url.pathname.endsWith("/")
) {
// If the last element of the path has a "." it's a file
const isFile = url.pathname.split("/").at(-1)?.includes(".");

Expand Down
5 changes: 5 additions & 0 deletions src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ export interface RouterOptions {
* @default {undefined}
*/
basePath?: string;
/**
* Disables the trailing slash redirect.
* @default {false}
*/
disableTrailingSlashRedirect?: boolean;
}

export type RenderFunction = (
Expand Down
41 changes: 41 additions & 0 deletions tests/disable_trailing_slash_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { assertStringIncludes } from "./deps.ts";
import { withFakeServe } from "./test_utils.ts";

Deno.test("control", async () => {
await withFakeServe(
"./tests/fixture_disable_trailing_slash_redirect/dev.ts",
async (server) => {
const res = await server.get("/about");
const content = await res.text();
assertStringIncludes(content, "<div>about</div>");
},
{ loadConfig: true },
);
});

Deno.test("404 with slash", async () => {
await withFakeServe(
"./tests/fixture_disable_trailing_slash_redirect/dev.ts",
async (server) => {
// the router doesn't support defining routes like this, but we should at least be able to detect this.
// without this feature this test won't work, since the redirect occurs before our middleware can handle it
const res = await server.get("/about/");
const content = await res.text();
assertStringIncludes(content, "<p>Has trailing slash: Yes</p>");
},
{ loadConfig: true },
);
});

Deno.test("404 without slash", async () => {
await withFakeServe(
"./tests/fixture_disable_trailing_slash_redirect/dev.ts",
async (server) => {
// just for good measure ensure our middleware properly works for "normal" errors
const res = await server.get("/foo");
const content = await res.text();
assertStringIncludes(content, "<p>Has trailing slash: No</p>");
},
{ loadConfig: true },
);
});
35 changes: 35 additions & 0 deletions tests/fixture_disable_trailing_slash_redirect/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
"manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"lint": {
"rules": {
"tags": [
"fresh",
"recommended"
]
}
},
"exclude": [
"**/_fresh/*"
],
"imports": {
"$fresh/": "file:///Users/reed/code/denoland/fresh/",
"preact": "https://esm.sh/[email protected]",
"preact/": "https://esm.sh/[email protected]/",
"@preact/signals": "https://esm.sh/*@preact/[email protected]",
"@preact/signals-core": "https://esm.sh/*@preact/[email protected]",
"$std/": "https://deno.land/[email protected]/"
},
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
8 changes: 8 additions & 0 deletions tests/fixture_disable_trailing_slash_redirect/dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/

import dev from "$fresh/dev.ts";
import config from "./fresh.config.ts";

import "$std/dotenv/load.ts";

await dev(import.meta.url, "./main.ts", config);
3 changes: 3 additions & 0 deletions tests/fixture_disable_trailing_slash_redirect/fresh.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from "$fresh/server.ts";

export default defineConfig({ router: { disableTrailingSlashRedirect: true } });
21 changes: 21 additions & 0 deletions tests/fixture_disable_trailing_slash_redirect/fresh.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.

import * as $_404 from "./routes/_404.tsx";
import * as $_middleware from "./routes/_middleware.ts";
import * as $about from "./routes/about.tsx";

import { type Manifest } from "$fresh/server.ts";

const manifest = {
routes: {
"./routes/_404.tsx": $_404,
"./routes/_middleware.ts": $_middleware,
"./routes/about.tsx": $about,
},
islands: {},
baseUrl: import.meta.url,
} satisfies Manifest;

export default manifest;
Loading
Loading