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

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

Closed
7 tasks done
noahmpauls opened this issue Jan 22, 2022 · 15 comments · Fixed by #14756
Closed
7 tasks done

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

noahmpauls opened this issue Jan 22, 2022 · 15 comments · Fixed by #14756
Labels
inconsistency Inconsistency between dev & build p2-nice-to-have Not breaking anything but nice to have (priority)
Milestone

Comments

@noahmpauls
Copy link

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
Copy link
Member

bluwy commented Apr 4, 2022

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.

@bluwy bluwy added bug p2-nice-to-have Not breaking anything but nice to have (priority) and removed pending triage labels Apr 4, 2022
@Pyrolistical
Copy link

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

@haoqunjiang haoqunjiang added the inconsistency Inconsistency between dev & build label Apr 24, 2022
@emma-k-alexandra
Copy link

emma-k-alexandra commented May 1, 2022

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
Copy link

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
Copy link

L422Y commented Nov 4, 2022

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

@iamTMTY
Copy link

iamTMTY commented Apr 25, 2023

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
Copy link

langpavel commented Jun 13, 2023

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

@bluwy bluwy added this to the 5.0 milestone Jun 20, 2023
@sni-J
Copy link

sni-J commented Jul 26, 2023

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.

@Haprog
Copy link

Haprog commented Aug 18, 2023

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
Copy link

@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
Copy link

regchiu commented Sep 5, 2023

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

@Haprog
Copy link

Haprog commented Sep 12, 2023

@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
Copy link

nkonev commented Oct 4, 2023

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
Copy link

ThaJay commented Oct 9, 2023

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

@nkonev
Copy link

nkonev commented Oct 10, 2023

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

@github-actions github-actions bot locked and limited conversation to collaborators Nov 14, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
inconsistency Inconsistency between dev & build p2-nice-to-have Not breaking anything but nice to have (priority)
Projects
None yet