From 3b664769968e49d5c55f2945ab83cd642b4a9439 Mon Sep 17 00:00:00 2001 From: italo jose Date: Mon, 23 Mar 2026 17:05:44 -0300 Subject: [PATCH 1/4] refactor: replace Node.js 'url' module with the native URL API across various packages and tools --- packages/ddp-server/stream_server.js | 9 ++++---- packages/force-ssl/force_ssl_server.js | 3 +-- packages/meteor/url_server.js | 2 +- packages/minifier-css/minifier.js | 23 ++++++++++-------- packages/oauth/oauth_common.js | 6 ++--- packages/oauth1/oauth1_server.js | 24 ++++++------------- packages/oauth1/oauth1_tests.js | 32 ++++++++++++++++++++++++++ packages/url/bc/url_server.js | 5 ++-- tools/cordova/builder.js | 3 +-- tools/meteor-services/auth.js | 5 ++-- tools/utils/utils.js | 20 ++++++++-------- tools/utils/utils.test.js | 16 +++++++++++++ 12 files changed, 90 insertions(+), 58 deletions(-) diff --git a/packages/ddp-server/stream_server.js b/packages/ddp-server/stream_server.js index 9cbdb4d2e2..e17ad68f37 100644 --- a/packages/ddp-server/stream_server.js +++ b/packages/ddp-server/stream_server.js @@ -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); diff --git a/packages/force-ssl/force_ssl_server.js b/packages/force-ssl/force_ssl_server.js index 6c31cc6217..37fe92a913 100644 --- a/packages/force-ssl/force_ssl_server.js +++ b/packages/force-ssl/force_ssl_server.js @@ -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. diff --git a/packages/meteor/url_server.js b/packages/meteor/url_server.js index 0c62c8080e..33aa997b1d 100644 --- a/packages/meteor/url_server.js +++ b/packages/meteor/url_server.js @@ -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"); diff --git a/packages/minifier-css/minifier.js b/packages/minifier-css/minifier.js index c5f3d2d97e..5481db1a58 100644 --- a/packages/minifier-css/minifier.js +++ b/packages/minifier-css/minifier.js @@ -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 diff --git a/packages/oauth/oauth_common.js b/packages/oauth/oauth_common.js index 3da322b2e7..493fcf5653 100644 --- a/packages/oauth/oauth_common.js +++ b/packages/oauth/oauth_common.js @@ -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 = { diff --git a/packages/oauth1/oauth1_server.js b/packages/oauth1/oauth1_server.js index f025a3c197..ae8a6ac559 100644 --- a/packages/oauth1/oauth1_server.js +++ b/packages/oauth1/oauth1_server.js @@ -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 diff --git a/packages/oauth1/oauth1_tests.js b/packages/oauth1/oauth1_tests.js index 7ad8419015..3ddf568889 100644 --- a/packages/oauth1/oauth1_tests.js +++ b/packages/oauth1/oauth1_tests.js @@ -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 = { diff --git a/packages/url/bc/url_server.js b/packages/url/bc/url_server.js index 21349958d8..b980802151 100644 --- a/packages/url/bc/url_server.js +++ b/packages/url/bc/url_server.js @@ -1,8 +1,7 @@ -var url_util = require('url'); var common = require("./url_common.js"); -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, diff --git a/tools/cordova/builder.js b/tools/cordova/builder.js index ab8e8a4b03..4258519ff1 100644 --- a/tools/cordova/builder.js +++ b/tools/cordova/builder.js @@ -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, diff --git a/tools/meteor-services/auth.js b/tools/meteor-services/auth.js index dd593583ba..ab16dd88d9 100644 --- a/tools/meteor-services/auth.js +++ b/tools/meteor-services/auth.js @@ -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 diff --git a/tools/utils/utils.js b/tools/utils/utils.js index 54bbfe0b31..3597dbd101 100644 --- a/tools/utils/utils.js +++ b/tools/utils/utils.js @@ -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'); @@ -38,13 +36,13 @@ exports.parseUrl = function (str, defaults) { 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(/\:$/, ''); var ret = { - protocol: hasScheme ? parsed.protocol : defaultProtocol, + protocol: hasScheme ? parsedProtocol : defaultProtocol, hostname: parsed.hostname || defaultHostname, port: parsed.port || defaultPort }; @@ -57,12 +55,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 () { diff --git a/tools/utils/utils.test.js b/tools/utils/utils.test.js index faf5a522fa..75b820b2f7 100644 --- a/tools/utils/utils.test.js +++ b/tools/utils/utils.test.js @@ -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], From 167426f1d16e5937b50932e8de6494ba7711838d Mon Sep 17 00:00:00 2001 From: italo jose Date: Mon, 23 Mar 2026 19:52:04 -0300 Subject: [PATCH 2/4] refactor: Replace deprecated `url.parse()` with WHATWG `new URL()` API, using `globalThis.URL` in `url/bc` and reconstructing query objects in `webapp`. --- packages/url/bc/url_server.js | 2 +- packages/webapp/webapp_server.js | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/url/bc/url_server.js b/packages/url/bc/url_server.js index b980802151..8a79d585ec 100644 --- a/packages/url/bc/url_server.js +++ b/packages/url/bc/url_server.js @@ -1,7 +1,7 @@ var common = require("./url_common.js"); exports._constructUrl = function (urlString, query, params) { - var url_parts = new URL(urlString); + var url_parts = new globalThis.URL(urlString); return common.buildUrl( url_parts.protocol + "//" + url_parts.host + url_parts.pathname, url_parts.search, diff --git a/packages/webapp/webapp_server.js b/packages/webapp/webapp_server.js index ad76808974..b252682685 100644 --- a/packages/webapp/webapp_server.js +++ b/packages/webapp/webapp_server.js @@ -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) { From 3f4bf5b76dd607487acfa7467e13dfb049a9b65c Mon Sep 17 00:00:00 2001 From: italo jose Date: Tue, 24 Mar 2026 10:23:49 -0300 Subject: [PATCH 3/4] fix: preserve raw IPv6 address formatting when parsing URLs. --- tools/utils/utils.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tools/utils/utils.js b/tools/utils/utils.js index 3597dbd101..91bce25d80 100644 --- a/tools/utils/utils.js +++ b/tools/utils/utils.js @@ -31,6 +31,11 @@ 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; @@ -41,9 +46,16 @@ exports.parseUrl = function (str, defaults) { // for consistency remove colon at the end of protocol 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 ? parsedProtocol : defaultProtocol, - hostname: parsed.hostname || defaultHostname, + hostname: hostname, port: parsed.port || defaultPort }; if (parsed.pathname !== '/' && parsed.pathname) { From 04281498a023e9ff89e040d99d489557f858ef94 Mon Sep 17 00:00:00 2001 From: italo jose Date: Tue, 24 Mar 2026 11:05:14 -0300 Subject: [PATCH 4/4] refactor: use `URL` from the npm `url` package and improve `METEOR_MODERN` environment variable parsing. --- packages/url/bc/url_server.js | 3 ++- tools/tool-env/meteor-config.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/url/bc/url_server.js b/packages/url/bc/url_server.js index 8a79d585ec..86c1ca7bcc 100644 --- a/packages/url/bc/url_server.js +++ b/packages/url/bc/url_server.js @@ -1,7 +1,8 @@ var common = require("./url_common.js"); +var { URL } = Npm.require('url'); exports._constructUrl = function (urlString, query, params) { - var url_parts = new globalThis.URL(urlString); + var url_parts = new URL(urlString); return common.buildUrl( url_parts.protocol + "//" + url_parts.host + url_parts.pathname, url_parts.search, diff --git a/tools/tool-env/meteor-config.js b/tools/tool-env/meteor-config.js index f92c707acb..eb836bdf93 100644 --- a/tools/tool-env/meteor-config.js +++ b/tools/tool-env/meteor-config.js @@ -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');