Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): update webpack-dev-server to v…
Browse files Browse the repository at this point in the history
…ersion 4

BREAKING CHANGE:

The dev-server now uses WebSockets to communicate changes to the browser during HMR and live-reloaded. If during your development you are using a proxy you will need to enable proxying of WebSockets.
  • Loading branch information
alan-agius4 committed Aug 27, 2021
1 parent a0b5897 commit 32dbf65
Show file tree
Hide file tree
Showing 10 changed files with 441 additions and 1,280 deletions.
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -124,7 +124,7 @@
"@types/semver": "^7.0.0",
"@types/text-table": "^0.2.1",
"@types/uuid": "^8.0.0",
"@types/webpack-dev-server": "^3.1.7",
"@types/webpack-dev-server": "^4.0.3",
"@typescript-eslint/eslint-plugin": "4.29.3",
"@typescript-eslint/parser": "4.29.3",
"@yarnpkg/lockfile": "1.1.0",
Expand Down Expand Up @@ -225,7 +225,7 @@
"verdaccio-auth-memory": "^10.0.0",
"webpack": "5.51.1",
"webpack-dev-middleware": "5.0.0",
"webpack-dev-server": "3.11.2",
"webpack-dev-server": "4.0.0",
"webpack-merge": "5.8.0",
"webpack-subresource-integrity": "5.0.0",
"zone.js": "^0.11.3"
Expand Down
2 changes: 1 addition & 1 deletion packages/angular_devkit/build_angular/package.json
Expand Up @@ -68,7 +68,7 @@
"tslib": "2.3.1",
"webpack": "5.51.1",
"webpack-dev-middleware": "5.0.0",
"webpack-dev-server": "3.11.2",
"webpack-dev-server": "4.0.0",
"webpack-merge": "5.8.0",
"webpack-subresource-integrity": "5.0.0"
},
Expand Down
Expand Up @@ -228,40 +228,6 @@ export function serveWebpackBrowser(
throw new Error('Webpack Dev Server configuration was not set.');
}

if (options.liveReload && !options.hmr) {
// This is needed because we cannot use the inline option directly in the config
// because of the SuppressExtractedTextChunksWebpackPlugin
// Consider not using SuppressExtractedTextChunksWebpackPlugin when liveReload is enable.
webpackDevServer.addDevServerEntrypoints(config, {
...config.devServer,
inline: true,
});

// Remove live-reload code from all entrypoints but not main.
// Otherwise this will break SuppressExtractedTextChunksWebpackPlugin because
// 'addDevServerEntrypoints' adds addional entry-points to all entries.
if (
config.entry &&
typeof config.entry === 'object' &&
!Array.isArray(config.entry) &&
config.entry.main
) {
for (const [key, value] of Object.entries(config.entry)) {
if (key === 'main' || !Array.isArray(value)) {
continue;
}

const webpackClientScriptIndex = value.findIndex((x) =>
x.includes('webpack-dev-server/client/index.js'),
);
if (webpackClientScriptIndex >= 0) {
// Remove the webpack-dev-server/client script from array.
value.splice(webpackClientScriptIndex, 1);
}
}
}
}

let locale: string | undefined;
if (i18n.shouldInline) {
// Dev-server only supports one locale
Expand Down Expand Up @@ -306,7 +272,7 @@ export function serveWebpackBrowser(
// The below is needed as otherwise HMR for CSS will break.
// styles.js and runtime.js needs to be loaded as a non-module scripts as otherwise `document.currentScript` will be null.
// https://github.com/webpack-contrib/mini-css-extract-plugin/blob/90445dd1d81da0c10b9b0e8a17b417d0651816b8/src/hmr/hotModuleReplacement.js#L39
isHMREnabled: webpackConfig.devServer?.hot,
isHMREnabled: !!webpackConfig.devServer?.hot,
});

webpackConfig.plugins ??= [];
Expand Down Expand Up @@ -336,8 +302,8 @@ export function serveWebpackBrowser(
const serverAddress = url.format({
protocol: options.ssl ? 'https' : 'http',
hostname: options.host === '0.0.0.0' ? 'localhost' : options.host,
pathname: webpackConfig.devServer?.publicPath,
port: buildEvent.port,
pathname: webpackConfig.devServer?.devMiddleware?.publicPath,
});

if (index === 0) {
Expand Down
Expand Up @@ -10,7 +10,6 @@
import { Architect, BuilderRun } from '@angular-devkit/architect';
import { tags } from '@angular-devkit/core';
import { createProxyServer } from 'http-proxy';
import { HTTPResponse } from 'puppeteer/lib/cjs/puppeteer/api-docs-entry';
import { Browser } from 'puppeteer/lib/cjs/puppeteer/common/Browser';
import { Page } from 'puppeteer/lib/cjs/puppeteer/common/Page';
import puppeteer from 'puppeteer/lib/cjs/puppeteer/node';
Expand Down Expand Up @@ -96,15 +95,33 @@ function createProxy(target: string, secure: boolean, ws = true): ProxyInstance
};
}

async function goToPageAndWaitForSockJs(page: Page, url: string): Promise<void> {
const socksRequest = `${url.endsWith('/') ? url : url + '/'}sockjs-node/info?t=`;
async function goToPageAndWaitForWS(page: Page, url: string): Promise<void> {
const baseUrl = url.replace(/^http/, 'ws');
const socksRequest = baseUrl[baseUrl.length - 1] === '/' ? `${baseUrl}ws` : `${baseUrl}/ws`;
// Create a Chrome dev tools session so that we can capturing websocket request.
// https://github.com/puppeteer/puppeteer/issues/2974

// We do this, to ensure that we make the right request with the expected host, port etc...
const client = await page.target().createCDPSession();
await client.send('Network.enable');
await client.send('Page.enable');

await Promise.all([
page.waitForResponse(
(r: HTTPResponse) => r.url().startsWith(socksRequest) && r.status() === 200,
),
new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`A Websocket connected to ${socksRequest} was not established.`)),
2000,
);
client.on('Network.webSocketCreated', ({ url }) => {
if (url.startsWith(socksRequest)) {
clearTimeout(timeout);
resolve();
}
});
}),
page.goto(url),
]);
await client.detach();
}

describe('Dev Server Builder live-reload', () => {
Expand Down Expand Up @@ -169,7 +186,7 @@ describe('Dev Server Builder live-reload', () => {
const url = buildEvent.baseUrl as string;
switch (buildCount) {
case 0:
await goToPageAndWaitForSockJs(page, url);
await goToPageAndWaitForWS(page, url);
host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-live-reload'`);
break;
case 1:
Expand Down Expand Up @@ -200,7 +217,7 @@ describe('Dev Server Builder live-reload', () => {
switch (buildCount) {
case 0:
proxy = createProxy(url, false);
await goToPageAndWaitForSockJs(page, proxy.url);
await goToPageAndWaitForWS(page, proxy.url);
host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-live-reload'`);
break;
case 1:
Expand Down Expand Up @@ -231,43 +248,7 @@ describe('Dev Server Builder live-reload', () => {
switch (buildCount) {
case 0:
proxy = createProxy(url, true);
await goToPageAndWaitForSockJs(page, proxy.url);
host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-live-reload'`);
break;
case 1:
const innerText = await page.evaluate(() => document.querySelector('p').innerText);
expect(innerText).toBe('app-live-reload');
break;
}

buildCount++;
}),
take(2),
)
.toPromise();
});

it('works without https -> http proxy without websockets (dotnet emulation)', async () => {
const run = await architect.scheduleTarget(target, overrides);
runs.push(run);

let proxy: ProxyInstance | undefined;
let buildCount = 0;

await run.output
.pipe(
debounceTime(1000),
switchMap(async (buildEvent) => {
expect(buildEvent.success).toBe(true);
const url = buildEvent.baseUrl as string;
switch (buildCount) {
case 0:
proxy = createProxy(url, true, false);
await goToPageAndWaitForSockJs(page, proxy.url);
await page.waitForResponse(
(response: HTTPResponse) =>
response.url().includes('xhr_streaming') && response.status() === 200,
);
await goToPageAndWaitForWS(page, proxy.url);
host.replaceInFile('src/app/app.component.ts', `'app'`, `'app-live-reload'`);
break;
case 1:
Expand Down
Expand Up @@ -27,7 +27,9 @@ export async function executeOnceAndFetch<T>(
mergeMap(async (executionResult) => {
let response = undefined;
if (executionResult.result?.success) {
const resolvedUrl = new URL(url, `${executionResult.result.baseUrl}/`);
let baseUrl = `${executionResult.result.baseUrl}`;
baseUrl = baseUrl[baseUrl.length - 1] === '/' ? baseUrl : `${baseUrl}/`;
const resolvedUrl = new URL(url, baseUrl);
response = await fetch(resolvedUrl, options?.request);
}

Expand Down
Expand Up @@ -390,6 +390,9 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
syncWebAssembly: true,
asyncWebAssembly: true,
},
infrastructureLogging: {
level: buildOptions.verbose ? 'verbose' : 'error',
},
cache: getCacheSettings(wco, buildBrowserFeatures.supportedBrowsers),
optimization: {
minimizer: extraMinimizers,
Expand Down
Expand Up @@ -12,56 +12,30 @@ import { posix, resolve } from 'path';
import * as url from 'url';
import * as webpack from 'webpack';
import { Configuration } from 'webpack-dev-server';
import { normalizeOptimization } from '../../utils';
import { WebpackConfigOptions, WebpackDevServerOptions } from '../../utils/build-options';
import { getIndexOutputFile } from '../../utils/webpack-browser-config';
import { HmrLoader } from '../plugins/hmr/hmr-loader';
import { getWatchOptions } from '../utils/helpers';

export function getDevServerConfig(
wco: WebpackConfigOptions<WebpackDevServerOptions>,
): webpack.Configuration {
const {
buildOptions: {
optimization,
host,
port,
index,
headers,
poll,
ssl,
hmr,
main,
disableHostCheck,
liveReload,
allowedHosts,
watch,
proxyConfig,
},
buildOptions: { host, port, index, headers, watch, hmr, main, liveReload, proxyConfig },
logger,
root,
} = wco;

const servePath = buildServePath(wco.buildOptions, logger);
const { styles: stylesOptimization, scripts: scriptsOptimization } = normalizeOptimization(
optimization,
);

const extraPlugins = [];

// Resolve public host and client address.
let publicHost = wco.buildOptions.publicHost;
if (publicHost) {
if (!/^\w+:\/\//.test(publicHost)) {
publicHost = `${ssl ? 'https' : 'http'}://${publicHost}`;
}

const parsedHost = url.parse(publicHost);
publicHost = parsedHost.host ?? undefined;
} else {
publicHost = '0.0.0.0:0';
const extraRules: webpack.RuleSetRule[] = [];
if (hmr) {
extraRules.push({
loader: HmrLoader,
include: [main].map((p) => resolve(wco.root, p)),
});
}

const extraPlugins = [];
if (!watch) {
// There's no option to turn off file watching in webpack-dev-server, but
// we can override the file watcher instead.
Expand All @@ -76,13 +50,7 @@ export function getDevServerConfig(
});
}

const extraRules: webpack.RuleSetRule[] = [];
if (hmr) {
extraRules.push({
loader: HmrLoader,
include: [main].map((p) => resolve(wco.root, p)),
});
}
const webSocketPath = posix.join(servePath, 'ws');

return {
plugins: extraPlugins,
Expand All @@ -97,7 +65,7 @@ export function getDevServerConfig(
...headers,
},
historyApiFallback: !!index && {
index: `${servePath}/${getIndexOutputFile(index)}`,
index: posix.join(servePath, getIndexOutputFile(index)),
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
rewrites: [
Expand All @@ -107,30 +75,31 @@ export function getDevServerConfig(
},
],
},
sockPath: posix.join(servePath, 'sockjs-node'),
stats: false,
compress: stylesOptimization.minify || scriptsOptimization,
watchOptions: getWatchOptions(poll),
webSocketServer: {
options: {
path: webSocketPath,
},
},
compress: false,
static: false,
https: getSslConfig(root, wco.buildOptions),
overlay: {
errors: !(stylesOptimization.minify || scriptsOptimization),
warnings: false,
allowedHosts: getAllowedHostsConfig(wco.buildOptions),
devMiddleware: {
publicPath: servePath,
stats: false,
},
public: publicHost,
allowedHosts,
disableHostCheck,
// This should always be true, but at the moment this breaks 'SuppressExtractedTextChunksWebpackPlugin'
// because it will include addition JS in the styles.js.
inline: hmr,
publicPath: servePath,
liveReload,
injectClient: liveReload,
hotOnly: hmr && !liveReload,
hot: hmr,
hot: hmr && !liveReload ? 'only' : hmr,
proxy: addProxyConfig(root, proxyConfig),
contentBase: false,
logLevel: 'error',
} as Configuration & { logLevel: Configuration['clientLogLevel'] },
client: {
logging: 'info',
webSocketURL: getPublicHostOptions(wco.buildOptions, webSocketPath),
overlay: {
errors: true,
warnings: false,
},
},
},
};
}

Expand Down Expand Up @@ -169,7 +138,7 @@ export function buildServePath(
* Private method to enhance a webpack config with SSL configuration.
* @private
*/
function getSslConfig(root: string, options: WebpackDevServerOptions) {
function getSslConfig(root: string, options: WebpackDevServerOptions): Configuration['https'] {
const { ssl, sslCert, sslKey } = options;
if (ssl && sslCert && sslKey) {
return {
Expand Down Expand Up @@ -235,3 +204,26 @@ function findDefaultServePath(baseHref?: string, deployUrl?: string): string | n
// Join together baseHref and deployUrl
return `${normalizedBaseHref}${deployUrl || ''}`;
}

function getAllowedHostsConfig(options: WebpackDevServerOptions): Configuration['allowedHosts'] {
if (options.disableHostCheck) {
return 'all';
} else if (options.allowedHosts?.length) {
return options.allowedHosts;
}

return undefined;
}

function getPublicHostOptions(options: WebpackDevServerOptions, webSocketPath: string): string {
let publicHost: string | null | undefined = options.publicHost;
if (publicHost) {
if (!/^\w+:\/\//.test(publicHost)) {
publicHost = `https://${publicHost}`;
}

publicHost = url.parse(publicHost).host;
}

return `auto://${publicHost || '0.0.0.0:0'}${webSocketPath}`;
}

1 comment on commit 32dbf65

@mbx2015
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello

I got an issue in dev env with dev-server when I have a websocket endpoint url like this /ws which make the dev-server enter in a loop of reloading,
I found the issue is in this line const const webSocketPath = posix.join(servePath, 'ws'); which is added to the version 13.0.0, I make a workaround in the node_modules > @angular/devkit > ..> dev-server by changing this line to const webSocketPath = posix.join(servePath, 'hot-reload'); , so please consider change this in the next patch.
You can test this issue by a simple nodejs server websocket listening on /ws.
Thanks.

Please sign in to comment.