vitejs / vite

Next generation frontend tooling. It's fast!
http://vite.dev
MIT License
67.81k stars 6.1k forks source link

Inconsistent URL trailing slash behavior between dev and preview servers #6596

Closed noahmpauls closed 11 months ago

noahmpauls commented 2 years ago

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:

├── package.json
├── vite.config.js
├── index.html
└── nested
    └── index.html

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, shows nested/index.html when visiting <root>/nested.

Reproduction

https://github.com/noahmpauls/vite-bug-multipage-url

System Info

System:
    OS: Windows 10 10.0.19043
    CPU: (8) x64 Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz
    Memory: 1.02 GB / 7.75 GB
  Binaries:
    Node: 14.15.5 - C:\Program Files\nodejs\node.EXE
    Yarn: 1.22.5 - C:\Program Files (x86)\Yarn\bin\yarn.CMD
    npm: 6.14.11 - C:\Program Files\nodejs\npm.CMD
  npmPackages:
    vite: ^2.7.2 => 2.7.13

Used Package Manager

npm

Logs

No response

Validations

bluwy commented 2 years ago

It seems that it's because Vite only handles trailing slash in dev:

https://github.com/vitejs/vite/blob/cf59005a79dd7227f3ad63280761060ba224ce09/packages/vite/src/node/server/middlewares/spaFallback.ts#L15

I tried to refactored to .* but I'm not sure if that causes any side effects down the line for the middlewares.

Pyrolistical commented 2 years ago

This should be configurable in order to properly emulate a production CDN which could be configured either way.

emma-k-alexandra commented 2 years ago

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))
  ]
})
philjones88 commented 2 years ago

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?

L422Y commented 1 year ago

Now that Nuxt is also using vite, I imagine this is going to cause a lot more headaches

iamTMTY commented 1 year ago

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()
    })
  }
})
langpavel commented 1 year ago

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

sni-J commented 1 year ago

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

~~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)~~

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.

Haprog commented 1 year ago

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;
}
Pyrolistical commented 1 year ago

@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

regchiu commented 1 year ago

So that’s it, no wonder I use <a href="/nested"> which only works in production environment.

Haprog commented 1 year ago

@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).

nkonev commented 1 year ago

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"
  }
}
ThaJay commented 1 year ago

These workarounds are horrendous. It would be great if something could be merged that fixes this issue.

nkonev commented 1 year ago

@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.