vite: Inconsistent URL trailing slash behavior between dev and preview servers

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

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 21
  • Comments: 15 (1 by maintainers)

Most upvoted comments

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?

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))
  ]
})

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

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

  • starts with slash (local request)
  • ignore if ‘@’ is included in path (ex. /@fs/*, /@react-refresh, /@vite/client, …)
  • have leaf route which trailing slash is missing
  • ignore if ‘.’ is included in leaf route -> ignore asset file url
    • extension-less asset files would not filtered out…?

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

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

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

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()
    })
  }
})

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