Skip to content

Commit 89c43b5

Browse files
authored
Add Cloudflare Pages middleware to handle trailing slash redirects (#1631)
## Problem Cloudflare Pages has a limitation where: - `/wandb/config/` → redirects correctly to `/ref/python/sdk/functions/init/` (via _redirects) - `/wandb/config` → returns 404 (doesn't match the redirect rule) Recently, we discovered that this results in a Google Search Console report with over 1000 404s that never hit the Cloudflare redirects at all. ## Solution We've implemented a Cloudflare Pages Function middleware that automatically adds trailing slashes to URLs before they're processed by the redirect rules. ### How it works 1. The middleware intercepts all GET/HEAD requests 2. For URLs without trailing slashes (and not file extensions or system files): - It checks if adding a slash would match a redirect rule - If yes, it redirects to the URL with a trailing slash 3. The browser then follows the redirect and hits the actual redirect rule ### Example flow 1. User visits: `/wandb/config` 2. Middleware redirects to: `/wandb/config/` (301) 3. _redirects rule matches and redirects to: `/ref/python/sdk/functions/init/` (301) 4. Final destination served: `/ref/python/sdk/functions/init/` ## Implementation ### Files created/modified: 1. **`/functions/_middleware.js`** - The edge function that handles trailing slash redirects 2. **`/public/_routes.json`** - Configuration to ensure the middleware runs on all routes ## Tested Scenarios ### ✅ Working correctly: 1. **Redirect rules without trailing slashes** - `/wandb/config` → `/wandb/config/` → `/ref/python/sdk/functions/init/` - Preserves query parameters: `/wandb/config?test=param` → `/wandb/config/?test=param` 2. **Files with extensions (not redirected)** - `/wandb/config.html` → 404 (no redirect) - `/style.css` → served as-is - `/script.js` → served as-is 3. **Paths already with trailing slashes (passed through)** - `/index/` → processed normally - `/wandb/config/` → triggers redirect rule directly 4. **System files (not redirected)** - `/robots.txt` → served as-is - `/favicon.ico` → served as-is 5. **Directory paths** - `/guides` → `/guides/` (when directory exists) 6. **Special paths** - `/` → works correctly (already has slash) - `/path.with.dots` → processed normally (no common file extension) ### Edge cases handled: - Query parameters are preserved during redirect - Hash fragments are preserved during redirect - Only common file extensions skip the middleware (.html, .css, .js, etc.) - Paths with dots but uncommon extensions are processed normally ## Deployment Instructions The middleware will be automatically deployed to production when this PR is merged. These files are the key: - `/functions/_middleware.js` - The middleware code - `/public/_routes.json` - Routes configuration (optional, but recommended) ## Testing This has been tested locally by using Wrangler, since it's not so easy to test it in the Hugo layer. The middleware is deployed in this PR's preview build for easy testing. The Cloudflare build logs in this PR show: ``` 13:33:47.656 | Successfully read wrangler.toml file. -- | -- 13:33:47.657 | Found Functions directory at /functions. Uploading. 13:33:47.665 | ⛅️ wrangler 3.101.0 13:33:47.665 | ------------------- 13:33:48.705 | ✨ Compiled Worker successfully ``` Test these URLs without the trailing slash, and they should redirect as described: From | To ------|----- https://cloudflare-trailing-slash-ed.docodile.pages.dev/wandb/config | https://cloudflare-trailing-slash-ed.docodile.pages.dev/ref/python/sdk/functions/init/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/library/init | https://cloudflare-trailing-slash-ed.docodile.pages.dev/ref/python/sdk/functions/init/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/artifacts/api | https://cloudflare-trailing-slash-ed.docodile.pages.dev/ref/python/artifact/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/sweeps/quickstart | https://cloudflare-trailing-slash-ed.docodile.pages.dev/guides/sweeps/walkthrough/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/frameworks/pytorch | https://cloudflare-trailing-slash-ed.docodile.pages.dev/guides/integrations/pytorch/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/library/hyperparameter-sweeps | https://cloudflare-trailing-slash-ed.docodile.pages.dev/guides/sweeps/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/guides/self-hosted | https://cloudflare-trailing-slash-ed.docodile.pages.dev/guides/hosting/hosting-options/self-managed/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/company/company-faq | https://cloudflare-trailing-slash-ed.docodile.pages.dev/support/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/ref/data_types | https://cloudflare-trailing-slash-ed.docodile.pages.dev/ref/python/data-types/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/weave/boards | https://cloudflare-trailing-slash-ed.docodile.pages.dev/guides/app/features/panels/query-panels/ https://cloudflare-trailing-slash-ed.docodile.pages.dev/wandb/config?test=param | https://cloudflare-trailing-slash-ed.docodile.pages.dev/ref/python/sdk/functions/init/?test=param (is a no-op but preserves the query parameter https://cloudflare-trailing-slash-ed.docodile.pages.dev/ref/release-notes/0.73#v0731 | https://cloudflare-trailing-slash-ed.docodile.pages.dev/ref/release-notes/0.73/#v0731 (preeserves the anchor) If you are observing in the browser inspector or the Cloudflare logs, each test should result in two redirects: 1. 301 redirect adding the trailing slash (example: `/wandb/config` → `/wandb/config/`) 2. 301 redirect to the final destination (example: `/wandb/config/` → `/ref/python/sdk/functions/init/`)
1 parent 0b8eac7 commit 89c43b5

2 files changed

Lines changed: 121 additions & 0 deletions

File tree

functions/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Cloudflare Pages Functions
2+
3+
This directory contains Cloudflare Pages Functions that enhance the functionality of the docs site.
4+
5+
## Middleware
6+
7+
### `_middleware.js` - Trailing Slash Redirect Handler
8+
9+
This middleware addresses a known Cloudflare Pages issue where URLs without trailing slashes don't match redirect rules in the `_redirects` file.
10+
11+
**Problem it solves:**
12+
- `/wandb/config` → 404 (doesn't match redirect rule)
13+
- `/wandb/config/` → redirects correctly
14+
15+
**How it works:**
16+
1. Intercepts requests to URLs without trailing slashes
17+
2. Checks if adding a slash would match a redirect rule
18+
3. If yes, redirects to the URL with a trailing slash
19+
4. The browser then follows the actual redirect rule
20+
21+
**Example:**
22+
- User visits: `/wandb/config`
23+
- Middleware redirects to: `/wandb/config/`
24+
- _redirects rule redirects to: `/ref/python/sdk/functions/init/`
25+
26+
This is a temporary solution until the migration off Cloudflare Pages is completed.

functions/_middleware.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Cloudflare Pages middleware to handle trailing slash redirects
3+
*
4+
* This middleware addresses the known Cloudflare Pages issue where URLs without
5+
* trailing slashes don't match redirect rules that include trailing slashes.
6+
*/
7+
8+
export async function onRequest(context) {
9+
const { request, env, next } = context;
10+
const url = new URL(request.url);
11+
12+
// Only process GET and HEAD requests
13+
if (request.method !== 'GET' && request.method !== 'HEAD') {
14+
return next();
15+
}
16+
17+
// Skip if the path already has a trailing slash
18+
if (url.pathname.endsWith('/')) {
19+
return next();
20+
}
21+
22+
// Skip if the path has a file extension
23+
const hasFileExtension = /\.[a-zA-Z0-9]+$/.test(url.pathname);
24+
if (hasFileExtension) {
25+
return next();
26+
}
27+
28+
// Skip certain system paths
29+
const skipPaths = ['/robots.txt', '/sitemap.xml', '/_redirects', '/favicon.ico'];
30+
if (skipPaths.includes(url.pathname)) {
31+
return next();
32+
}
33+
34+
// Skip paths with dots that might not be file extensions
35+
// but could be version numbers or other valid path segments
36+
// Only skip if the dot is followed by common file extensions
37+
const commonExtensions = /\.(html|htm|css|js|json|xml|txt|pdf|jpg|jpeg|png|gif|svg|ico|woff|woff2|ttf|eot)$/i;
38+
if (commonExtensions.test(url.pathname)) {
39+
return next();
40+
}
41+
42+
// Create URL with trailing slash, preserving query and hash
43+
const urlWithSlash = new URL(request.url);
44+
urlWithSlash.pathname = url.pathname + '/';
45+
46+
// Create a new request with the trailing slash
47+
const requestWithSlash = new Request(urlWithSlash.toString(), {
48+
method: request.method,
49+
headers: request.headers,
50+
redirect: 'manual' // Important: don't follow redirects
51+
});
52+
53+
try {
54+
// Fetch with the trailing slash URL
55+
const response = await env.ASSETS.fetch(requestWithSlash);
56+
57+
// If we get a redirect response (301, 302, etc.), redirect to URL with slash
58+
if (response.status >= 301 && response.status <= 308) {
59+
// The path with trailing slash matches a redirect rule
60+
// Redirect the browser to the URL with slash, which will then trigger the actual redirect
61+
// Preserve query parameters and hash
62+
const redirectUrl = new URL(request.url);
63+
redirectUrl.pathname = url.pathname + '/';
64+
65+
return new Response(null, {
66+
status: 301,
67+
headers: {
68+
'Location': redirectUrl.pathname + redirectUrl.search + redirectUrl.hash,
69+
'Cache-Control': 'public, max-age=3600'
70+
}
71+
});
72+
}
73+
74+
// If the response is successful (200), it means the trailing slash version exists
75+
// In this case, we should also redirect to maintain consistency
76+
if (response.status === 200) {
77+
const redirectUrl = new URL(request.url);
78+
redirectUrl.pathname = url.pathname + '/';
79+
80+
return new Response(null, {
81+
status: 301,
82+
headers: {
83+
'Location': redirectUrl.pathname + redirectUrl.search + redirectUrl.hash,
84+
'Cache-Control': 'public, max-age=3600'
85+
}
86+
});
87+
}
88+
} catch (error) {
89+
// Log error but continue with original request
90+
console.error('Trailing slash middleware error:', error);
91+
}
92+
93+
// No redirect found, continue with the original request
94+
return next();
95+
}

0 commit comments

Comments
 (0)