Merge pull request #14248 from meteor/node/url-parse

refactor: replace Node.js 'url' module with the native URL API
This commit is contained in:
Italo José
2026-03-24 14:43:38 -03:00
committed by GitHub
14 changed files with 109 additions and 64 deletions

View File

@@ -327,16 +327,15 @@ Object.assign(StreamServer.prototype, {
// Store arguments for use within the closure below
var args = arguments;
// TODO replace with url package
var url = Npm.require('url');
// Rewrite /websocket and /websocket/ urls to /sockjs/websocket while
// preserving query string.
var parsedUrl = url.parse(request.url);
var parsedUrl = new URL(request.url, 'http://localhost');
if (parsedUrl.pathname === pathPrefix + '/websocket' ||
parsedUrl.pathname === pathPrefix + '/websocket/') {
parsedUrl.pathname = self.prefix + '/websocket';
request.url = url.format(parsedUrl);
request.url = parsedUrl.search
? parsedUrl.pathname + parsedUrl.search
: parsedUrl.pathname;
}
oldHttpServerListeners.forEach(function(oldListener) {
oldListener.apply(httpServer, args);

View File

@@ -1,4 +1,3 @@
var url = Npm.require("url");
import { isLocalConnection, isSslConnection } from 'meteor/force-ssl-common';
// Unfortunately we can't use a connect middleware here since
@@ -23,7 +22,7 @@ httpServer.addListener('request', function (req, res) {
if (!isLocalConnection(req) && !isSslConnection(req)) {
// connection is not cool. send a 302 redirect!
var host = url.parse(Meteor.absoluteUrl()).hostname;
var host = new URL(Meteor.absoluteUrl()).hostname;
// strip off the port number. If we went to a URL with a custom
// port, we don't know what the custom SSL port is anyway.

View File

@@ -2,7 +2,7 @@ if (process.env.ROOT_URL &&
typeof __meteor_runtime_config__ === "object") {
__meteor_runtime_config__.ROOT_URL = process.env.ROOT_URL;
if (__meteor_runtime_config__.ROOT_URL) {
var parsedUrl = Npm.require('url').parse(__meteor_runtime_config__.ROOT_URL);
var parsedUrl = new URL(__meteor_runtime_config__.ROOT_URL);
// Sometimes users try to pass, eg, ROOT_URL=mydomain.com.
if (!parsedUrl.host || ['http:', 'https:'].indexOf(parsedUrl.protocol) === -1) {
throw Error("$ROOT_URL, if specified, must be an URL");

View File

@@ -1,5 +1,4 @@
import path from 'path';
import url from 'url';
import postcss from 'postcss';
import cssnano from 'cssnano';
@@ -236,25 +235,29 @@ const rewriteRules = (rules, mergedCssPath) => {
while (parts = cssUrlRegex.exec(value)) {
const oldCssUrl = parts[0];
const quote = parts[1];
const resource = url.parse(parts[2]);
const cssUrlValue = parts[2];
// We don't rewrite URLs starting with a protocol definition such as
// http, https, or data, or those with network-path references
// i.e. //img.domain.com/cat.gif
if (resource.protocol !== null ||
resource.href.startsWith('//') ||
resource.href.startsWith('#')) {
if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(cssUrlValue) ||
cssUrlValue.startsWith('//') ||
cssUrlValue.startsWith('#')) {
continue;
}
// Rewrite relative paths (that refers to the internal application tree)
// to absolute paths (addressable from the public build).
let absolutePath = isRelative(resource.path)
? pathJoin(basePath, resource.path)
: resource.path;
const hashIndex = cssUrlValue.indexOf('#');
const hash = hashIndex >= 0 ? cssUrlValue.slice(hashIndex) : '';
const pathPart = hashIndex >= 0 ? cssUrlValue.slice(0, hashIndex) : cssUrlValue;
if (resource.hash) {
absolutePath += resource.hash;
let absolutePath = isRelative(pathPart)
? pathJoin(basePath, pathPart)
: pathPart;
if (hash) {
absolutePath += hash;
}
// We used to finish the rewriting process at the absolute path step

View File

@@ -18,7 +18,6 @@ OAuth._redirectUri = (serviceName, config, params, absoluteUrlOptions) => {
}
if (Meteor.isServer && isCordova) {
const url = Npm.require('url');
let rootUrl = process.env.MOBILE_ROOT_URL ||
__meteor_runtime_config__.ROOT_URL;
@@ -28,12 +27,11 @@ OAuth._redirectUri = (serviceName, config, params, absoluteUrlOptions) => {
// XXX Maybe we should put this in a separate package or something
// that is used here and by boilerplate-generator? Or maybe
// `Meteor.absoluteUrl` should know how to do this?
const parsedRootUrl = url.parse(rootUrl);
const parsedRootUrl = new URL(rootUrl);
if (parsedRootUrl.hostname === "localhost") {
parsedRootUrl.hostname = "10.0.2.2";
delete parsedRootUrl.host;
}
rootUrl = url.format(parsedRootUrl);
rootUrl = parsedRootUrl.toString();
}
absoluteUrlOptions = {

View File

@@ -1,27 +1,17 @@
import url from 'url';
import { OAuth1Binding } from './oauth1_binding';
OAuth._queryParamsWithAuthTokenUrl = (authUrl, oauthBinding, params = {}, whitelistedQueryParams = []) => {
const redirectUrlObj = url.parse(authUrl, true);
const redirectUrl = new URL(authUrl);
Object.assign(
redirectUrlObj.query,
whitelistedQueryParams.reduce((prev, param) =>
params.query[param] ? { ...prev, param: params.query[param] } : prev,
{}
),
{
oauth_token: oauthBinding.requestToken,
whitelistedQueryParams.forEach(param => {
if (params.query && params.query[param]) {
redirectUrl.searchParams.set(param, params.query[param]);
}
);
});
// Clear the `search` so it is rebuilt by Node's `url` from the `query` above.
// Using previous versions of the Node `url` module, this was just set to ""
// However, Node 6 docs seem to indicate that this should be `undefined`.
delete redirectUrlObj.search;
redirectUrl.searchParams.set('oauth_token', oauthBinding.requestToken);
// Reconstruct the URL back with provided query parameters merged with oauth_token
return url.format(redirectUrlObj);
return redirectUrl.toString();
};
// connect middleware

View File

@@ -169,6 +169,38 @@ Tinytest.add("oauth1 - headers are encoded correctly", test => {
);
});
Tinytest.add("oauth1 - _queryParamsWithAuthTokenUrl adds oauth_token to URL", test => {
const mockBinding = { requestToken: 'my-token' };
const result = OAuth._queryParamsWithAuthTokenUrl(
'https://provider.com/oauth?existing=1', mockBinding, {}, []
);
const parsed = new URL(result);
test.equal(parsed.searchParams.get('oauth_token'), 'my-token');
test.equal(parsed.searchParams.get('existing'), '1');
});
Tinytest.add("oauth1 - _queryParamsWithAuthTokenUrl merges whitelisted query params", test => {
const mockBinding = { requestToken: 'tok' };
const result = OAuth._queryParamsWithAuthTokenUrl(
'https://provider.com/oauth', mockBinding,
{ query: { screen_name: 'user1', secret: 'x' } },
['screen_name']
);
const parsed = new URL(result);
test.equal(parsed.searchParams.get('screen_name'), 'user1');
test.isNull(parsed.searchParams.get('secret'));
});
Tinytest.add("oauth1 - _queryParamsWithAuthTokenUrl excludes non-whitelisted params", test => {
const mockBinding = { requestToken: 'tok' };
const result = OAuth._queryParamsWithAuthTokenUrl(
'https://provider.com/oauth', mockBinding,
{ query: { not_allowed: 'val' } }, []
);
const parsed = new URL(result);
test.isNull(parsed.searchParams.get('not_allowed'));
});
Tinytest.add("oauth1 - auth header string is built correctly", test => {
const binding = new OAuth1Binding();
const headers = {

View File

@@ -1,8 +1,8 @@
var url_util = require('url');
var common = require("./url_common.js");
var { URL } = Npm.require('url');
exports._constructUrl = function (url, query, params) {
var url_parts = url_util.parse(url);
exports._constructUrl = function (urlString, query, params) {
var url_parts = new URL(urlString);
return common.buildUrl(
url_parts.protocol + "//" + url_parts.host + url_parts.pathname,
url_parts.search,

View File

@@ -3,7 +3,6 @@ import { readFileSync, chmodSync, chownSync } from 'fs';
import { createServer } from 'http';
import { userInfo } from 'os';
import { join as pathJoin, dirname as pathDirname } from 'path';
import { parse as parseUrl } from 'url';
import { createHash } from 'crypto';
import express from 'express';
import compress from 'compression';
@@ -162,7 +161,7 @@ WebApp.categorizeRequest = function(req) {
modern,
path,
arch: WebApp.defaultArch,
url: parseUrl(req.url, true),
url: { query: Object.fromEntries(new URL(req.url, 'http://localhost').searchParams) },
dynamicHead: req.dynamicHead,
dynamicBody: req.dynamicBody,
headers: req.headers,
@@ -810,7 +809,7 @@ async function runWebAppServer() {
var syncQueue = new Meteor._AsynchronousQueue();
var getItemPathname = function(itemUrl) {
return decodeURIComponent(parseUrl(itemUrl).pathname);
return decodeURIComponent(new URL(itemUrl, 'http://localhost').pathname);
};
WebAppInternals.reloadClientPrograms = async function() {
@@ -1098,7 +1097,7 @@ async function runWebAppServer() {
// Strip off the path prefix, if it exists.
app.use(function(request, response, next) {
const pathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX;
const { pathname, search } = parseUrl(request.url);
const { pathname, search } = new URL(request.url, 'http://localhost');
// check if the path in the url starts with the path prefix
if (pathPrefix) {

View File

@@ -1,5 +1,4 @@
import _ from 'underscore';
import url from 'url';
import { Console } from '../console/console.js';
import buildmessage from '../utils/buildmessage.js';
import files from '../fs/files';
@@ -548,7 +547,7 @@ export class CordovaBuilder {
const mobileServerUrl = this.options.mobileServerUrl;
const parsedUrl = url.parse(mobileServerUrl);
const parsedUrl = new URL(mobileServerUrl);
const runtimeConfig = {
meteorRelease: meteorRelease,

View File

@@ -5,7 +5,6 @@ var config = require('./config.js');
var httpHelpers = require('../utils/http-helpers.js');
var fiberHelpers = require('../utils/fiber-helpers.js');
var querystring = require('querystring');
var url = require('url');
var Console = require('../console/console.js').Console;
var auth = exports;
@@ -377,8 +376,8 @@ var sendAuthorizeRequest = async function (clientId, redirectUri, state) {
throw new Error('access-denied');
}
if (url.parse(response.headers.location).hostname !==
url.parse(redirectUri).hostname) {
if (new URL(response.headers.location).hostname !==
new URL(redirectUri).hostname) {
// If we didn't get an immediate redirect to the redirectUri then
// presumably the oauth server is trying to interact with us (make
// us log in, authorize the client, or something like that). We're

View File

@@ -50,7 +50,8 @@ export const normalizeModernConfig = (r = false) => Object.fromEntries(
* @returns {Object} - The initialized Meteor configuration object.
*/
export function initMeteorConfig(appDir = process.cwd()) {
const modernForced = JSON.parse(process.env.METEOR_MODERN || "false");
const rawModern = process.env.METEOR_MODERN;
const modernForced = JSON.parse(rawModern && rawModern !== 'undefined' ? rawModern : 'false');
let packageJson;
if (appDir) {
const packageJsonPath = files.pathJoin(appDir, 'package.json');

View File

@@ -1,8 +1,6 @@
var _ = require('underscore');
var semver = require('semver');
var os = require('os');
var url = require('url');
var archinfo = require('./archinfo');
var buildmessage = require('./buildmessage.js');
var files = require('../fs/files');
@@ -33,19 +31,31 @@ exports.parseUrl = function (str, defaults) {
protocol: defaultProtocol };
}
// Capture any IPv6 address in brackets before new URL() normalizes it
// (e.g. "0000:...0001" gets compressed to "::1" by the WHATWG parser).
var ipv6Match = str.match(/\[([^\]]+)\]/);
var rawIPv6 = ipv6Match ? ipv6Match[1] : null;
var hasScheme = exports.hasScheme(str);
if (! hasScheme) {
str = "http://" + str;
}
var parsed = url.parse(str);
var parsed = new URL(str);
// for consistency remove colon at the end of protocol
parsed.protocol = parsed.protocol.replace(/\:$/, '');
var parsedProtocol = parsed.protocol.replace(/\:$/, '');
// WHATWG URL wraps IPv6 in brackets and normalizes the address; use the
// raw value extracted above to preserve the original formatting.
var hostname = parsed.hostname || defaultHostname;
if (hostname && hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = rawIPv6 || hostname.slice(1, -1);
}
var ret = {
protocol: hasScheme ? parsed.protocol : defaultProtocol,
hostname: parsed.hostname || defaultHostname,
protocol: hasScheme ? parsedProtocol : defaultProtocol,
hostname: hostname,
port: parsed.port || defaultPort
};
if (parsed.pathname !== '/' && parsed.pathname) {
@@ -57,12 +67,12 @@ exports.parseUrl = function (str, defaults) {
// 'options' is an object with 'hostname', 'port', and 'protocol' keys, such as
// the return value of parseUrl.
exports.formatUrl = function (options) {
// For consistency with `Meteor.absoluteUrl`, add a trailing slash to make
// this a valid URL
if (!options.pathname)
options.pathname = "/";
return url.format(options);
const u = new URL('http://placeholder');
u.protocol = options.protocol + ':';
u.hostname = options.hostname;
if (options.port) u.port = options.port;
u.pathname = options.pathname || '/';
return u.toString();
};
exports.ipAddress = function () {

View File

@@ -45,6 +45,7 @@ describe('parseUrl', () => {
['localhost', {}, { hostname: 'localhost' }],
['localhost:3000', {}, { hostname: 'localhost', port: '3000', protocol: undefined }],
['https://ex.com:8080/path', {}, { protocol: 'https', hostname: 'ex.com', port: '8080', pathname: '/path' }],
['http://ex.com/path?q=1', {}, { protocol: 'http', hostname: 'ex.com', pathname: '/path' }],
['ex.com:3000', { protocol: 'https' }, { protocol: 'https', hostname: 'ex.com', port: '3000' }],
['http://ex.com', { protocol: 'https' }, { protocol: 'http', hostname: 'ex.com' }],
['http://ex.com', { port: '9999' }, { protocol: 'http', hostname: 'ex.com', port: '9999' }],
@@ -58,6 +59,21 @@ describe('parseUrl', () => {
});
});
describe('formatUrl', () => {
test('constructs URL from hostname, protocol and port', () => {
expect(utils.formatUrl({ hostname: 'example.com', protocol: 'https', port: '8080' }))
.toBe('https://example.com:8080/');
});
test('includes pathname when provided', () => {
expect(utils.formatUrl({ hostname: 'example.com', protocol: 'http', pathname: '/app' }))
.toBe('http://example.com/app');
});
test('defaults to root path when no pathname given', () => {
expect(utils.formatUrl({ hostname: 'h.com', protocol: 'http' }))
.toBe('http://h.com/');
});
});
describe('hasScheme', () => {
test.each([
['http://x', true], ['https://x', true], ['git+ssh://x', true],