Skip to content

Commit 6e1ee7a

Browse files
authored
[cli] Fix dev query string merging (vercel#8183)
Since `vercel dev` performs query string merging with `vercel.json` routes and the requested url, there is sometimes a slight difference between the request that the browser sends and the request that the upstream dev server receives. Most servers treat the query string key with "empty" value the same as undefined value. Meaning, these two URLs should be equivalent because each has empty values: - `http://example.com/src/App.vue?vue&type=style&index=0&lang.css` - `http://example.com/src/App.vue?vue=&type=style&index=0&lang.css=` However, `vite dev` handles these two URLs differently because of [string comparisons](https://github.com/vitejs/vite/blob/2289d04af5398791c3a01c9e597dc976e593c852/packages/plugin-vue/src/handleHotUpdate.ts#L98-L99) instead of URL parsing, which causes requests from `vercel dev` to fail. Thus, this PR changes from Node.js native URL parsing to a custom implementation in order to handle this corner case and preserve the difference between `?a=` and `?a`. Ideally this would be [fixed in vite](vitejs/vite#9589), however we might run into other dev servers that also fail in the same way in the future. - Fixes vercel#7283 - Closes vitejs/vite#9589 - Related to nodejs/node#9500
1 parent 767ce2c commit 6e1ee7a

File tree

17 files changed

+643
-47
lines changed

17 files changed

+643
-47
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* This function is necessary to account for the difference between
3+
* `?a=` and `?a` because native `url.parse(str, true)` can't tell.
4+
* @param querystring - The querystring to parse, also known as the "search" string.
5+
*/
6+
export function parseQueryString(
7+
querystring?: string
8+
): Record<string, string[]> {
9+
const query: Record<string, string[]> = Object.create(null);
10+
if (!querystring || !querystring.startsWith('?') || querystring === '?') {
11+
return query;
12+
}
13+
const params = querystring.slice(1).split('&');
14+
for (let param of params) {
15+
let [key, value] = param.split('=');
16+
if (key !== undefined) {
17+
key = decodeURIComponent(key);
18+
}
19+
if (value !== undefined) {
20+
value = decodeURIComponent(value);
21+
}
22+
23+
let existing = query[key];
24+
if (!existing) {
25+
existing = [];
26+
query[key] = existing;
27+
}
28+
29+
existing.push(value);
30+
}
31+
return query;
32+
}
33+
34+
/**
35+
* This function is necessary to account for the difference between
36+
* `?a=` and `?a` because native `url.format({ query })` can't tell.
37+
* @param query - The query object to stringify.
38+
*/
39+
export function formatQueryString(
40+
query: Record<string, string[]> | undefined
41+
): string | undefined {
42+
if (!query) {
43+
return undefined;
44+
}
45+
let s = '';
46+
let prefix = '?';
47+
for (let [key, values] of Object.entries(query)) {
48+
for (let value of values) {
49+
s += prefix;
50+
s += encodeURIComponent(key);
51+
if (value !== undefined) {
52+
s += '=';
53+
s += encodeURIComponent(value);
54+
}
55+
prefix = '&';
56+
}
57+
}
58+
return s || undefined;
59+
}

packages/cli/src/util/dev/router.ts

+11-7
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import DevServer from './server';
66

77
import { VercelConfig, HttpHeadersConfig, RouteResult } from './types';
88
import { isHandler, Route, HandleValue } from '@vercel/routing-utils';
9+
import { parseQueryString } from './parse-query-string';
910

1011
export function resolveRouteParameters(
1112
str: string,
@@ -56,7 +57,8 @@ export async function devRouter(
5657
phase?: HandleValue | null
5758
): Promise<RouteResult> {
5859
let result: RouteResult | undefined;
59-
let { query, pathname: reqPathname = '/' } = url.parse(reqUrl, true);
60+
let { pathname: reqPathname = '/', search: reqSearch } = url.parse(reqUrl);
61+
const reqQuery = parseQueryString(reqSearch);
6062
const combinedHeaders: HttpHeadersConfig = { ...previousHeaders };
6163
let status: number | undefined;
6264
let isContinue = false;
@@ -174,7 +176,7 @@ export async function devRouter(
174176
isDestUrl,
175177
status: routeConfig.status || status,
176178
headers: combinedHeaders,
177-
uri_args: query,
179+
query: reqQuery,
178180
matched_route: routeConfig,
179181
matched_route_idx: idx,
180182
phase,
@@ -184,17 +186,19 @@ export async function devRouter(
184186
if (!destPath.startsWith('/')) {
185187
destPath = `/${destPath}`;
186188
}
187-
const destParsed = url.parse(destPath, true);
188-
Object.assign(destParsed.query, query);
189+
const { pathname: destPathname = '/', search: destSearch } =
190+
url.parse(destPath);
191+
const destQuery = parseQueryString(destSearch);
192+
Object.assign(destQuery, reqQuery);
189193
result = {
190194
found: true,
191-
dest: destParsed.pathname || '/',
195+
dest: destPathname,
192196
continue: isContinue,
193197
userDest: Boolean(routeConfig.dest),
194198
isDestUrl,
195199
status: routeConfig.status || status,
196200
headers: combinedHeaders,
197-
uri_args: destParsed.query,
201+
query: destQuery,
198202
matched_route: routeConfig,
199203
matched_route_idx: idx,
200204
phase,
@@ -212,7 +216,7 @@ export async function devRouter(
212216
continue: isContinue,
213217
status,
214218
isDestUrl: false,
215-
uri_args: query,
219+
query: reqQuery,
216220
headers: combinedHeaders,
217221
phase,
218222
};

packages/cli/src/util/dev/server.ts

+33-24
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import { ProjectEnvVariable, ProjectSettings } from '../../types';
9494
import exposeSystemEnvs from './expose-system-envs';
9595
import { treeKill } from '../tree-kill';
9696
import { nodeHeadersToFetchHeaders } from './headers';
97+
import { formatQueryString, parseQueryString } from './parse-query-string';
9798
import {
9899
errorToString,
99100
isErrnoException,
@@ -1396,9 +1397,11 @@ export default class DevServer {
13961397

13971398
const getReqUrl = (rr: RouteResult): string | undefined => {
13981399
if (rr.dest) {
1399-
if (rr.uri_args) {
1400-
const destParsed = url.parse(rr.dest, true);
1401-
Object.assign(destParsed.query, rr.uri_args);
1400+
if (rr.query) {
1401+
const destParsed = url.parse(rr.dest);
1402+
const destQuery = parseQueryString(destParsed.search);
1403+
Object.assign(destQuery, rr.query);
1404+
destParsed.search = formatQueryString(destQuery);
14021405
return url.format(destParsed);
14031406
}
14041407
return rr.dest;
@@ -1533,9 +1536,8 @@ export default class DevServer {
15331536

15341537
// Retain orginal pathname, but override query parameters from the rewrite
15351538
const beforeRewriteUrl = req.url || '/';
1536-
const rewriteUrlParsed = url.parse(beforeRewriteUrl, true);
1537-
delete rewriteUrlParsed.search;
1538-
rewriteUrlParsed.query = url.parse(rewritePath, true).query;
1539+
const rewriteUrlParsed = url.parse(beforeRewriteUrl);
1540+
rewriteUrlParsed.search = url.parse(rewritePath).search;
15391541
req.url = url.format(rewriteUrlParsed);
15401542
debug(
15411543
`Rewrote incoming HTTP URL from "${beforeRewriteUrl}" to "${req.url}"`
@@ -1594,9 +1596,10 @@ export default class DevServer {
15941596

15951597
if (routeResult.isDestUrl) {
15961598
// Mix the `routes` result dest query params into the req path
1597-
const destParsed = url.parse(routeResult.dest, true);
1598-
delete destParsed.search;
1599-
Object.assign(destParsed.query, routeResult.uri_args);
1599+
const destParsed = url.parse(routeResult.dest);
1600+
const destQuery = parseQueryString(destParsed.search);
1601+
Object.assign(destQuery, routeResult.query);
1602+
destParsed.search = formatQueryString(destQuery);
16001603
const destUrl = url.format(destParsed);
16011604

16021605
debug(`ProxyPass: ${destUrl}`);
@@ -1737,7 +1740,7 @@ export default class DevServer {
17371740
throw new Error('Expected Route Result but none was found.');
17381741
}
17391742

1740-
const { dest, headers, uri_args } = routeResult;
1743+
const { dest, query, headers } = routeResult;
17411744

17421745
// Set any headers defined in the matched `route` config
17431746
for (const [name, value] of Object.entries(headers)) {
@@ -1773,10 +1776,11 @@ export default class DevServer {
17731776
}
17741777

17751778
this.setResponseHeaders(res, requestId);
1776-
const origUrl = url.parse(req.url || '/', true);
1777-
delete origUrl.search;
1779+
const origUrl = url.parse(req.url || '/');
1780+
const origQuery = parseQueryString(origUrl.search);
17781781
origUrl.pathname = dest;
1779-
Object.assign(origUrl.query, uri_args);
1782+
Object.assign(origQuery, query);
1783+
origUrl.search = formatQueryString(origQuery);
17801784
req.url = url.format(origUrl);
17811785
return proxyPass(req, res, upstream, this, requestId, false);
17821786
}
@@ -1798,10 +1802,11 @@ export default class DevServer {
17981802
Array.isArray(buildResult.routes) &&
17991803
buildResult.routes.length > 0
18001804
) {
1801-
const origUrl = url.parse(req.url || '/', true);
1802-
delete origUrl.search;
1805+
const origUrl = url.parse(req.url || '/');
1806+
const origQuery = parseQueryString(origUrl.search);
18031807
origUrl.pathname = dest;
1804-
Object.assign(origUrl.query, uri_args);
1808+
Object.assign(origQuery, query);
1809+
origUrl.search = formatQueryString(origQuery);
18051810
const newUrl = url.format(origUrl);
18061811
debug(
18071812
`Checking build result's ${buildResult.routes.length} \`routes\` to match ${newUrl}`
@@ -1897,11 +1902,13 @@ export default class DevServer {
18971902
);
18981903

18991904
// Mix in the routing based query parameters
1900-
const parsed = url.parse(req.url || '/', true);
1901-
Object.assign(parsed.query, uri_args);
1905+
const origUrl = url.parse(req.url || '/');
1906+
const origQuery = parseQueryString(origUrl.search);
1907+
Object.assign(origQuery, query);
1908+
origUrl.search = formatQueryString(origQuery);
19021909
req.url = url.format({
1903-
pathname: parsed.pathname,
1904-
query: parsed.query,
1910+
pathname: origUrl.pathname,
1911+
search: origUrl.search,
19051912
});
19061913

19071914
// Add the Vercel platform proxy request headers
@@ -2017,11 +2024,13 @@ export default class DevServer {
20172024
requestId = generateRequestId(this.podId, true);
20182025

20192026
// Mix the `routes` result dest query params into the req path
2020-
const parsed = url.parse(req.url || '/', true);
2021-
Object.assign(parsed.query, uri_args);
2027+
const origUrl = url.parse(req.url || '/');
2028+
const origQuery = parseQueryString(origUrl.search);
2029+
Object.assign(origQuery, query);
2030+
origUrl.search = formatQueryString(origQuery);
20222031
const path = url.format({
2023-
pathname: parsed.pathname,
2024-
query: parsed.query,
2032+
pathname: origUrl.pathname,
2033+
search: origUrl.search,
20252034
});
20262035

20272036
const body = await rawBody(req);

packages/cli/src/util/dev/types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ export interface RouteResult {
145145
status?: number;
146146
// "headers": <object of the added response header values>
147147
headers: HttpHeadersConfig;
148-
// "uri_args": <object (key=value) list of new uri args to be passed along to dest >
149-
uri_args?: { [key: string]: any };
148+
// "query": <object (key=values) of new uri args to be passed along to dest>
149+
query?: Record<string, string[]>;
150150
// "matched_route": <object of the route spec that matched>
151151
matched_route?: Route;
152152
// "matched_route_idx": <integer of the index of the route matched>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules
2+
.DS_Store
3+
dist
4+
dist-ssr
5+
*.local
6+
.vercel
7+
!public
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" href="/favicon.ico" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Vite App</title>
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script type="module" src="/src/main.js"></script>
12+
</body>
13+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"private": true,
3+
"scripts": {
4+
"dev": "vite --port $PORT",
5+
"build": "vite build",
6+
"serve": "vite preview"
7+
},
8+
"dependencies": {
9+
"vue": "3.2.37"
10+
},
11+
"devDependencies": {
12+
"@vitejs/plugin-vue": "1.10.2",
13+
"@vue/compiler-sfc": "3.2.37",
14+
"vite": "2.9.14"
15+
}
16+
}
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<template>
2+
<img alt="Vue logo" src="./assets/logo.png" />
3+
<HelloWorld msg="Hello Vue 3 + Vite" />
4+
</template>
5+
6+
<script setup>
7+
import HelloWorld from './components/HelloWorld.vue'
8+
9+
// This starter template is using Vue 3 experimental <script setup> SFCs
10+
// Check out https://github.com/vuejs/rfcs/blob/script-setup-2/active-rfcs/0000-script-setup.md
11+
</script>
12+
13+
<style>
14+
#app {
15+
font-family: Avenir, Helvetica, Arial, sans-serif;
16+
-webkit-font-smoothing: antialiased;
17+
-moz-osx-font-smoothing: grayscale;
18+
text-align: center;
19+
color: #2c3e50;
20+
margin-top: 60px;
21+
}
22+
</style>
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<template>
2+
<h1>{{ msg }}</h1>
3+
4+
<p>
5+
<a href="https://vitejs.dev/guide/features.html" target="_blank">
6+
Vite Documentation
7+
</a>
8+
|
9+
<a href="https://v3.vuejs.org/" target="_blank">Vue 3 Documentation</a>
10+
</p>
11+
12+
<button type="button" @click="state.count++">count is: {{ state.count }}</button>
13+
<p>
14+
Edit
15+
<code>components/HelloWorld.vue</code> to test hot module replacement.
16+
</p>
17+
</template>
18+
19+
<script setup>
20+
import { defineProps, reactive } from 'vue'
21+
22+
defineProps({
23+
msg: String
24+
})
25+
26+
const state = reactive({ count: 0 })
27+
</script>
28+
29+
<style scoped>
30+
a {
31+
color: #42b983;
32+
}
33+
</style>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createApp } from 'vue';
2+
import App from './App.vue';
3+
4+
createApp(App).mount('#app');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'vite';
2+
import vue from '@vitejs/plugin-vue';
3+
4+
// https://vitejs.dev/config/
5+
export default defineConfig({
6+
plugins: [vue()],
7+
});

0 commit comments

Comments
 (0)