diff --git a/babel.config.js b/babel.config.js index bcdab71e08..5e587952f9 100644 --- a/babel.config.js +++ b/babel.config.js @@ -14,6 +14,7 @@ module.exports = (api) => { }, ], ], + plugins: ['@babel/plugin-transform-object-assign'], env: { test: { plugins: ['@babel/plugin-transform-runtime'], diff --git a/client-src/clients/SockJSClient.js b/client-src/clients/SockJSClient.js index 92ab084c86..e7981615b8 100644 --- a/client-src/clients/SockJSClient.js +++ b/client-src/clients/SockJSClient.js @@ -8,11 +8,12 @@ module.exports = class SockJSClient extends BaseClient { constructor(url) { super(); - const sockUrl = url.replace(/^(?:chrome-extension|file)/i, 'http'); - - this.sock = new SockJS(sockUrl); - this.sock.onerror = (err) => { - log.error(err); + // SockJS requires `http` and `https` protocols + this.sock = new SockJS( + url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:') + ); + this.sock.onerror = (error) => { + log.error(error); }; } diff --git a/client-src/clients/WebsocketClient.js b/client-src/clients/WebsocketClient.js index ebcde666f6..f748edea34 100644 --- a/client-src/clients/WebsocketClient.js +++ b/client-src/clients/WebsocketClient.js @@ -7,11 +7,9 @@ module.exports = class WebsocketClient extends BaseClient { constructor(url) { super(); - const wsUrl = url.replace(/^(?:http|chrome-extension|file)/i, 'ws'); - - this.client = new WebSocket(wsUrl); - this.client.onerror = (err) => { - log.error(err); + this.client = new WebSocket(url); + this.client.onerror = (error) => { + log.error(error); }; } diff --git a/client-src/index.js b/client-src/index.js index ba0e9c3ad3..1ff36f8497 100644 --- a/client-src/index.js +++ b/client-src/index.js @@ -2,19 +2,21 @@ /* global __resourceQuery WorkerGlobalScope */ +const webpackHotLog = require('webpack/hot/log'); const stripAnsi = require('./modules/strip-ansi'); +const parseURL = require('./utils/parseURL'); const socket = require('./socket'); const overlay = require('./overlay'); const { log, setLogLevel } = require('./utils/log'); const sendMessage = require('./utils/sendMessage'); const reloadApp = require('./utils/reloadApp'); -const createSocketUrl = require('./utils/createSocketUrl'); +const createSocketURL = require('./utils/createSocketURL'); const status = { isUnloading: false, currentHash: '', }; -const options = { +const defaultOptions = { hot: false, hotReload: true, liveReload: false, @@ -23,7 +25,40 @@ const options = { useErrorOverlay: false, useProgress: false, }; -const socketUrl = createSocketUrl(__resourceQuery); +const parsedResourceQuery = parseURL(__resourceQuery); +const options = defaultOptions; + +// Handle Node.js legacy format and `new URL()` +if (parsedResourceQuery.query) { + Object.assign(options, parsedResourceQuery.query); +} else if (parsedResourceQuery.searchParams) { + const paramsToObject = (entries) => { + const result = {}; + + for (const [key, value] of entries) { + result[key] = value; + } + + return result; + }; + + Object.assign( + options, + paramsToObject(parsedResourceQuery.searchParams.entries()) + ); +} + +const socketURL = createSocketURL(parsedResourceQuery); + +function setAllLogLevel(level) { + // This is needed because the HMR logger operate separately from dev server logger + webpackHotLog.setLogLevel(level); + setLogLevel(level); +} + +if (options.logging) { + setAllLogLevel(options.logging); +} self.addEventListener('beforeunload', () => { status.isUnloading = true; @@ -68,17 +103,7 @@ const onSocketMessage = { sendMessage('StillOk'); }, - logging: function logging(level) { - // this is needed because the HMR logger operate separately from - // dev server logger - const hotCtx = require.context('webpack/hot', false, /^\.\/log$/); - - if (hotCtx.keys().indexOf('./log') !== -1) { - hotCtx('./log').setLogLevel(level); - } - - setLogLevel(level); - }, + logging: setAllLogLevel, overlay(value) { if (typeof document !== 'undefined') { if (typeof value === 'boolean') { @@ -172,4 +197,4 @@ const onSocketMessage = { }, }; -socket(socketUrl, onSocketMessage); +socket(socketURL, onSocketMessage); diff --git a/client-src/utils/createSocketURL.js b/client-src/utils/createSocketURL.js new file mode 100644 index 0000000000..40e8cc4970 --- /dev/null +++ b/client-src/utils/createSocketURL.js @@ -0,0 +1,96 @@ +'use strict'; + +const url = require('url'); + +// We handle legacy API that is Node.js specific, and a newer API that implements the same WHATWG URL Standard used by web browsers +// Please look at https://nodejs.org/api/url.html#url_url_strings_and_url_objects +function createSocketURL(parsedURL) { + let { auth, hostname, protocol, port } = parsedURL; + + const getURLSearchParam = (name) => { + if (parsedURL.searchParams) { + return parsedURL.searchParams.get(name); + } + + return parsedURL.query && parsedURL.query[name]; + }; + + // Node.js module parses it as `::` + // `new URL(urlString, [baseURLstring])` parses it as '[::]' + const isInAddrAny = + hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]'; + + // check ipv4 and ipv6 `all hostname` + // why do we need this check? + // hostname n/a for file protocol (example, when using electron, ionic) + // see: https://github.com/webpack/webpack-dev-server/pull/384 + if ( + isInAddrAny && + self.location.hostname && + self.location.protocol.indexOf('http') === 0 + ) { + hostname = self.location.hostname; + } + + // `hostname` can be empty when the script path is relative. In that case, specifying a protocol would result in an invalid URL. + // When https is used in the app, secure websockets are always necessary because the browser doesn't accept non-secure websockets. + if (hostname && isInAddrAny && self.location.protocol === 'https:') { + protocol = self.location.protocol; + } + + const socketURLProtocol = protocol.replace( + /^(?:http|.+-extension|file)/i, + 'ws' + ); + + // `new URL(urlString, [baseURLstring])` doesn't have `auth` property + // Parse authentication credentials in case we need them + if (parsedURL.username) { + auth = parsedURL.username; + + // Since HTTP basic authentication does not allow empty username, + // we only include password if the username is not empty. + if (parsedURL.password) { + // Result: : + auth = auth.concat(':', parsedURL.password); + } + } + + const socketURLAuth = auth; + + // In case the host is a raw IPv6 address, it can be enclosed in + // the brackets as the brackets are needed in the final URL string. + // Need to remove those as url.format blindly adds its own set of brackets + // if the host string contains colons. That would lead to non-working + // double brackets (e.g. [[::]]) host + // + // All of these sock url params are optionally passed in through resourceQuery, + // so we need to fall back to the default if they are not provided + const socketURLHostname = ( + getURLSearchParam('host') || + hostname || + 'localhost' + ).replace(/^\[(.*)\]$/, '$1'); + + if (!port || port === '0') { + port = self.location.port; + } + + const socketURLPort = getURLSearchParam('port') || port; + + // If path is provided it'll be passed in via the resourceQuery as a + // query param so it has to be parsed out of the querystring in order for the + // client to open the socket to the correct location. + const socketURLPathname = getURLSearchParam('path') || '/ws'; + + return url.format({ + protocol: socketURLProtocol, + auth: socketURLAuth, + hostname: socketURLHostname, + port: socketURLPort, + pathname: socketURLPathname, + slashes: true, + }); +} + +module.exports = createSocketURL; diff --git a/client-src/utils/createSocketUrl.js b/client-src/utils/createSocketUrl.js deleted file mode 100644 index b2f7eba15d..0000000000 --- a/client-src/utils/createSocketUrl.js +++ /dev/null @@ -1,100 +0,0 @@ -'use strict'; - -const url = require('url'); -const getCurrentScriptSource = require('./getCurrentScriptSource'); - -function createSocketUrl(resourceQuery, currentLocation) { - let urlParts; - - if (typeof resourceQuery === 'string' && resourceQuery !== '') { - // If this bundle is inlined, use the resource query to get the correct url. - // format is like `?http://0.0.0.0:8096&port=8097&host=localhost` - urlParts = url.parse( - resourceQuery - // strip leading `?` from query string to get a valid URL - .substr(1) - // replace first `&` with `?` to have a valid query string - .replace('&', '?'), - true - ); - } else { - // Else, get the url from the