Closed noahmpauls closed 11 months ago
It seems that it's because Vite only handles trailing slash in dev:
I tried to refactored to .*
but I'm not sure if that causes any side effects down the line for the middlewares.
This should be configurable in order to properly emulate a production CDN which could be configured either way.
Was trying to work this out locally and wrote a plugin that seems to fix it:
https://gist.github.com/emma-k-alexandra/47ef18239e8a1e517160aff591e8132d
// forward-to-trailing-slash-plugin.js
/**
* Forwards routes in the given list to a route with a trailing slash in the dev server
* Useful for multi page vite apps where all rollup inputs are known.
*
* Vite fix is upcoming, which will make this plugin unnecessary
* https://github.com/vitejs/vite/issues/6596
*/
export default routes => ({
name: 'forward-to-trailing-slash',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
const requestURLwithoutLeadingSlash = req.url.substring(1)
if (routes.includes(requestURLwithoutLeadingSlash)) {
req.url = `${req.url}/`
}
next()
})
}
})
Example config:
// vite.config.js
import { defineConfig } from 'vite'
import forwardToTrailingSlashPlugin from './forward-to-trailing-slash-plugin.js'
const build = {
rollupOptions: {
input: {
main: new URL('./index.html', import.meta.url).href,
photography: new URL('./photography/index.html', import.meta.url).href
}
}
}
export default defineConfig({
build,
plugins: [
forwardToTrailingSlashPlugin(Object.keys(build.rollupOptions.input))
]
})
We've hit this inconsistency moving from Create React App/Craco to Vite.
We used to have /foo
but to try and make production and development closer we're going to have to change all our production urls to /foo/
to match development.
Seems an annoying rule?
Now that Nuxt is also using vite, I imagine this is going to cause a lot more headaches
This works fine but doesn't return assets(css styles, javascript or typescript files) in the directory so I upgraded the plugin to this:
import { ViteDevServer } from "vite"
export default (routes: string[]) => ({
name: "forward-to-trailing-slash",
configureServer(server: ViteDevServer) {
server.middlewares.use((req, res, next) => {
const assets = ["ts", "css", "js"]
const requestURLwithoutLeadingSlash = req?.url?.substring(1)
const referrerWithoutTrailingSlash = req.headers.referer?.split("/").pop()
const fileExtension = req.url?.split(".").pop()
if (routes.includes(requestURLwithoutLeadingSlash || "")) {
req.url = `${req.url}/`
}
if(routes.includes(referrerWithoutTrailingSlash || "") && assets.includes(fileExtension || "")) {
req.url = `/${referrerWithoutTrailingSlash}${req.url}`
}
next()
})
}
})
I refactor this to something similar:
import { ViteDevServer } from 'vite';
const assetExtensions = new Set([
'cjs',
'css',
'graphql',
'ico',
'jpeg',
'jpg',
'js',
'json',
'map',
'mjs',
'png',
'sass',
'scss',
'svg',
'ts',
'tsx',
]);
export default () => ({
name: 'forward-to-trailing-slash',
configureServer(server: ViteDevServer) {
server.middlewares.use((req, res, next) => {
const { url, headers } = req;
const startsWithAt = url?.startsWith('/@');
if (startsWithAt) {
return next();
}
const startsWithDot = url?.startsWith('/.');
if (startsWithDot) {
return next();
}
const realUrl = new URL(
url ?? '.',
`${headers[':scheme'] ?? 'http'}://${headers[':authority'] ?? headers.host}`,
);
const endsWithSlash = realUrl.pathname.endsWith('/');
if (!endsWithSlash) {
const ext = realUrl.pathname.split('.').pop();
if (!ext || !assetExtensions.has(ext)) {
realUrl.pathname = `${realUrl.pathname}/`;
req.url = `${realUrl.pathname}${realUrl.search}`;
}
}
return next();
});
},
});
EDIT: This does not work with base
URL
Had same issue, here's another solution using regex
{
name: "forward-to-trailing-slash",
configureServer: (server) => {
server.middlewares.use((req, res, next) => {
if (!req.url) {
return next();
}
const requestURL = new URL(req.url, `http://${req.headers.host}`);
if (/^\/(?:[^@]+\/)*[^@./]+$/g.test(requestURL.pathname)) {
requestURL.pathname += "/";
req.url = requestURL.toString();
}
return next();
});
},
}
Regex represents
/@fs/*
, /@react-refresh
, /@vite/client
, ...)~~Chose to use res.writeHead
instead of setting req.url
, since resolving relative path in nested/index.html
created error
(another inconsistency with preview but more like desired behavior)~~
<root>/nested/
: ./main.tsx
in nested/index.html
-> <root>/nested/main.tsx
<root>/nested
: ./main.tsx
in nested/index.html
-> <root>/main.tsx
(Missing!)Edit
If nested route has its own router (ex. react-router), using relative path in nested/index.html
creates same problem above in its subpath (<root>/nested/abc
)
Changed ./main.tsx
to /nested/main.tsx
in nested/index.html
, inconsistency above became not required thus reverted to setting req.url
.
As an easy workaround for simple cases you may get away with just adding a simple redirect in your main/root app from the nested app's URL without trailing slash to the same URL with trailing slash.
For example in my React project to workaround this issue for one nested app (app using react-router-dom
for routing) I basically added a route in the main app with path '/nested'
and set the route element to a component <RedirectToNestedSite />
which is defined like so:
function RedirectToNestedSite() {
// Redirect to nested app without keeping any state from this app
window.location.replace(`/nested/`);
return null;
}
@Haprog I don't think you are talking about the same issue. This isn't something that be fixed with client side routing as the wrong bundle will be loaded. See the original post. There are two different bundles being served
So that’s it, no wonder I use <a href="/nested">
which only works in production environment.
@Haprog I don't think you are talking about the same issue. This isn't something that be fixed with client side routing as the wrong bundle will be loaded. See the original post. There are two different bundles being served
That's why my suggested workaround modifies window.location
directly to make the browser do the navigation (including full page load) instead of using client side routing to navigate. Client side routing is only used here to trigger the browser native navigation when the user arrives on the problematic URL without trailing slash (the case that loads the wrong bundle initially, but here the wrong bundle with the workaround knows what to do and redirects to the correct one).
My version for Vue 3 and Vuetify, with base
URL
vite.config.js
:
// Plugins
import vue from '@vitejs/plugin-vue'
import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import forwardToTrailingSlashPlugin from './forward-to-trailing-slash-plugin.js'
import anotherEntrypointIndexHtml from "./another-entrypoint-index-html";
// Utilities
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import { resolve } from 'path';
// https://vitejs.dev/config/
const base = "/front2";
export default defineConfig({
base: base,
build: {
rollupOptions: {
input: {
appMain: resolve(__dirname, 'index.html'),
appBlog: resolve(__dirname, 'blog', 'index.html'),
},
},
},
plugins: [
forwardToTrailingSlashPlugin(base),
anotherEntrypointIndexHtml(base, "/blog"),
vue({
template: { transformAssetUrls }
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/next/packages/vite-plugin
vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss',
},
}),
],
define: { 'process.env': {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
extensions: [
'.js',
'.json',
'.jsx',
'.mjs',
'.ts',
'.tsx',
'.vue',
],
},
server: {
port: 3000,
strictPort: true,
},
})
forward-to-trailing-slash-plugin.js
:
// workaround - removes the need of the trailing slash https://github.com/vitejs/vite/issues/6596
export default (base) => ({
name: 'forward-to-trailing-slash',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const { url, headers } = req;
const normalizedBase = base ? base : "";
const startsWithAt = url?.startsWith(`${normalizedBase}/@`);
if (startsWithAt) {
return next();
}
// needed for dynamic routing components in vue
const startsWithSrc = url?.startsWith(`${normalizedBase}/src`);
if (startsWithSrc) {
return next();
}
const startsNodeModules = url?.startsWith(`${normalizedBase}/node_modules`);
if (startsNodeModules) {
return next();
}
const realUrl = new URL(
url ?? '.',
`${headers[':scheme'] ?? 'http'}://${headers[':authority'] ?? headers.host}`,
);
const endsWithSlash = realUrl.pathname.endsWith('/');
if (!endsWithSlash) {
realUrl.pathname = `${realUrl.pathname}/`;
req.url = `${realUrl.pathname}${realUrl.search}`;
}
return next();
});
},
});
another-entrypoint-index-html.js
:
// fixes the first load the appropriate index.html for /blog/<whatever>
export default (base, subroute) => ({
name: "another-entrypoint-index-html",
configureServer(server) {
server.middlewares.use(
(req, res, next) => {
const { url, headers } = req;
const realUrl = new URL(
url ?? '.',
`${headers[':scheme'] ?? 'http'}://${headers[':authority'] ?? headers.host}`,
);
if (realUrl.pathname.startsWith(`${base}${subroute}`)) {
realUrl.pathname = `${base}${subroute}/index.html`;
req.url = `${realUrl.pathname}${realUrl.search}`;
}
return next();
}
)
}
})
[nkonev@fedora frontend2]$ tree -L 2
.
├── blog
│ └── index.html
├── forward-to-trailing-slash-plugin.js
├── index.html
├── jsconfig.json
├── node_modules
├── package.json
├── package-lock.json
├── public
│ ├── favicon2.svg
│ └── favicon_new2.svg
├── README.md
├── src
│ ├── App.vue
│ ├── BlogApp.vue
│ ├── blogMain.js
│ └── main.js
└── vite.config.js
Urls are
http://localhost:3000/front2
http://localhost:3000/front2/blog
package.json
:
{
"name": "frontend2",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --fix --ignore-path .gitignore"
},
"dependencies": {
"typeface-roboto": "1.1.13",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@mdi/font": "7.2.96",
"@tiptap/extension-color": "2.0.3",
"@tiptap/extension-highlight": "2.0.3",
"@tiptap/extension-image": "2.0.3",
"@tiptap/extension-link": "2.0.3",
"@tiptap/extension-mention": "2.0.3",
"@tiptap/extension-placeholder": "2.0.3",
"@tiptap/extension-text-style": "2.0.3",
"@tiptap/extension-underline": "2.0.3",
"@tiptap/pm": "2.0.3",
"@tiptap/starter-kit": "2.0.3",
"@tiptap/suggestion": "2.0.3",
"@tiptap/vue-3": "2.0.3",
"axios": "^1.4.0",
"core-js": "^3.31.1",
"date-fns": "^2.30.0",
"graphql-ws": "^5.11.2",
"lodash": "^4.17.21",
"mark.js": "^8.11.1",
"mitt": "^3.0.1",
"pinia": "^2.1.4",
"splitpanes": "^3.1.5",
"uuid": "^9.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"vuetify": "3.3.15"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"eslint": "^8.45.0",
"eslint-plugin-vue": "^9.15.1",
"sass": "^1.63.6",
"stylus": "^0.59.0",
"vite": "^4.4.4",
"vite-plugin-vuetify": "^1.0.2"
}
}
These workarounds are horrendous. It would be great if something could be merged that fixes this issue.
@bluwy
Is it possible to include one of provided workarounds to the vite codebase ?
Or some better solution.
For instance, my workaround isn't perfect at all, but it works good with base
URL.
Describe the bug
Multi-page apps created with Vite do not behave consistently between dev and build preview when visiting nested URLs that do not have a trailing slash.
Using the following folder structure:
Expected: Both dev and build servers have consistent behavior when visiting
<root>/nested
Actual: Dev server shows
index.html
from root when visiting<root>/nested
; must use<root>/nested/
instead. Build preview, however, showsnested/index.html
when visiting<root>/nested
.Reproduction
https://github.com/noahmpauls/vite-bug-multipage-url
System Info
Used Package Manager
npm
Logs
No response
Validations