Skip to content
This repository was archived by the owner on Apr 8, 2020. It is now read-only.

Commit 93a7da3

Browse files
To avoid client-side errors, support proxying websocket requests
Because Angular CLI tries to use a Websocket connection to get notifications of code updates. If the proxy server rejects such connections, you get client-side errors.
1 parent 1634bbf commit 93a7da3

File tree

3 files changed

+306
-134
lines changed

3 files changed

+306
-134
lines changed

src/Microsoft.AspNetCore.SpaServices/AngularCli/AngularCliMiddleware.cs

Lines changed: 31 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.AspNetCore.Hosting;
6+
using Microsoft.AspNetCore.NodeServices;
7+
using Microsoft.AspNetCore.SpaServices.Proxy;
48
using System;
59
using System.IO;
6-
using Microsoft.AspNetCore.NodeServices;
10+
using System.Net.Http;
711
using System.Threading.Tasks;
8-
using Microsoft.AspNetCore.Builder;
9-
using Microsoft.AspNetCore.Hosting;
1012
using System.Threading;
11-
using Microsoft.AspNetCore.SpaServices.Proxy;
12-
using Microsoft.AspNetCore.Http;
1313

1414
namespace Microsoft.AspNetCore.SpaServices.AngularCli
1515
{
@@ -21,6 +21,8 @@ internal class AngularCliMiddleware
2121

2222
private readonly INodeServices _nodeServices;
2323
private readonly string _middlewareScriptPath;
24+
private readonly HttpClient _neverTimeOutHttpClient = ConditionalProxy.CreateHttpClientForProxy(
25+
Timeout.InfiniteTimeSpan);
2426

2527
public AngularCliMiddleware(IApplicationBuilder appBuilder, string sourcePath, SpaDefaultPageMiddleware defaultPageMiddleware)
2628
{
@@ -36,11 +38,30 @@ public AngularCliMiddleware(IApplicationBuilder appBuilder, string sourcePath, S
3638
// Start Angular CLI and attach to middleware pipeline
3739
var angularCliServerInfoTask = StartAngularCliServerAsync();
3840

39-
// Proxy the corresponding requests through ASP.NET and into the Node listener
40-
// Anything under /<publicpath> (e.g., /dist) is proxied as a normal HTTP request
41-
// with a typical timeout (100s is the default from HttpClient).
42-
UseProxyToLocalAngularCliMiddleware(appBuilder, defaultPageMiddleware,
43-
angularCliServerInfoTask, TimeSpan.FromSeconds(100));
41+
// Everything we proxy is hardcoded to target http://localhost because:
42+
// - the requests are always from the local machine (we're not accepting remote
43+
// requests that go directly to the Angular CLI middleware server)
44+
// - given that, there's no reason to use https, and we couldn't even if we
45+
// wanted to, because in general the Angular CLI server has no certificate
46+
var proxyOptionsTask = angularCliServerInfoTask.ContinueWith(
47+
task => new ConditionalProxyMiddlewareTarget(
48+
"http", "localhost", task.Result.Port.ToString()));
49+
50+
var applicationStoppingToken = GetStoppingToken(appBuilder);
51+
52+
// Proxy all requests into the Angular CLI server
53+
appBuilder.Use(async (context, next) =>
54+
{
55+
var didProxyRequest = await ConditionalProxy.PerformProxyRequest(
56+
context, _neverTimeOutHttpClient, proxyOptionsTask, applicationStoppingToken);
57+
58+
// Since we are proxying everything, this is the end of the middleware pipeline.
59+
// We won't call next().
60+
if (!didProxyRequest)
61+
{
62+
context.Response.StatusCode = 404;
63+
}
64+
});
4465

4566
// Advertise the availability of this feature to other SPA middleware
4667
appBuilder.Properties.Add(AngularCliMiddlewareKey, this);
@@ -104,51 +125,6 @@ private async Task<AngularCliServerInfo> StartAngularCliServerAsync()
104125
return angularCliServerInfo;
105126
}
106127

107-
private static void UseProxyToLocalAngularCliMiddleware(
108-
IApplicationBuilder appBuilder, SpaDefaultPageMiddleware defaultPageMiddleware,
109-
Task<AngularCliServerInfo> serverInfoTask, TimeSpan requestTimeout)
110-
{
111-
// This is hardcoded to use http://localhost because:
112-
// - the requests are always from the local machine (we're not accepting remote
113-
// requests that go directly to the Angular CLI middleware server)
114-
// - given that, there's no reason to use https, and we couldn't even if we
115-
// wanted to, because in general the Angular CLI server has no certificate
116-
var proxyOptionsTask = serverInfoTask.ContinueWith(
117-
task => new ConditionalProxyMiddlewareTarget(
118-
"http", "localhost", task.Result.Port.ToString()));
119-
120-
// Requests outside /<urlPrefix> are proxied to the default page
121-
var hasRewrittenUrlMarker = new object();
122-
var defaultPageUrl = defaultPageMiddleware.DefaultPageUrl;
123-
var urlPrefix = defaultPageMiddleware.UrlPrefix;
124-
var urlPrefixIsRoot = string.IsNullOrEmpty(urlPrefix) || urlPrefix == "/";
125-
appBuilder.Use((context, next) =>
126-
{
127-
if (!urlPrefixIsRoot && !context.Request.Path.StartsWithSegments(urlPrefix))
128-
{
129-
context.Items[hasRewrittenUrlMarker] = context.Request.Path;
130-
context.Request.Path = defaultPageUrl;
131-
}
132-
133-
return next();
134-
});
135-
136-
appBuilder.UseMiddleware<ConditionalProxyMiddleware>(urlPrefix, requestTimeout, proxyOptionsTask);
137-
138-
// If we rewrote the path, rewrite it back. Don't want to interfere with
139-
// any other middleware.
140-
appBuilder.Use((context, next) =>
141-
{
142-
if (context.Items.ContainsKey(hasRewrittenUrlMarker))
143-
{
144-
context.Request.Path = (PathString)context.Items[hasRewrittenUrlMarker];
145-
context.Items.Remove(hasRewrittenUrlMarker);
146-
}
147-
148-
return next();
149-
});
150-
}
151-
152128
#pragma warning disable CS0649
153129
class AngularCliServerInfo
154130
{
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Http;
5+
using System;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Net;
9+
using System.Net.Http;
10+
using System.Net.WebSockets;
11+
using System.Threading;
12+
using System.Threading.Tasks;
13+
14+
namespace Microsoft.AspNetCore.SpaServices.Proxy
15+
{
16+
internal static class ConditionalProxy
17+
{
18+
private const int DefaultWebSocketBufferSize = 4096;
19+
private const int StreamCopyBufferSize = 81920;
20+
21+
private static readonly string[] NotForwardedWebSocketHeaders = new[] { "Connection", "Host", "Upgrade", "Sec-WebSocket-Key", "Sec-WebSocket-Version" };
22+
23+
public static HttpClient CreateHttpClientForProxy(TimeSpan requestTimeout)
24+
{
25+
var handler = new HttpClientHandler
26+
{
27+
AllowAutoRedirect = false,
28+
UseCookies = false,
29+
30+
};
31+
32+
return new HttpClient(handler)
33+
{
34+
Timeout = requestTimeout
35+
};
36+
}
37+
38+
public static async Task<bool> PerformProxyRequest(
39+
HttpContext context,
40+
HttpClient httpClient,
41+
Task<ConditionalProxyMiddlewareTarget> targetTask,
42+
CancellationToken applicationStoppingToken)
43+
{
44+
// Stop proxying if either the server or client wants to disconnect
45+
var proxyCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(
46+
context.RequestAborted,
47+
applicationStoppingToken).Token;
48+
49+
// We allow for the case where the target isn't known ahead of time, and want to
50+
// delay proxied requests until the target becomes known. This is useful, for example,
51+
// when proxying to Angular CLI middleware: we won't know what port it's listening
52+
// on until it finishes starting up.
53+
var target = await targetTask;
54+
var targetUri = new UriBuilder(
55+
target.Scheme,
56+
target.Host,
57+
int.Parse(target.Port),
58+
context.Request.Path,
59+
context.Request.QueryString.Value).Uri;
60+
61+
try
62+
{
63+
if (context.WebSockets.IsWebSocketRequest)
64+
{
65+
await AcceptProxyWebSocketRequest(context, ToWebSocketScheme(targetUri), proxyCancellationToken);
66+
return true;
67+
}
68+
else
69+
{
70+
using (var requestMessage = CreateProxyHttpRequest(context, targetUri))
71+
using (var responseMessage = await SendProxyHttpRequest(context, httpClient, requestMessage, proxyCancellationToken))
72+
{
73+
return await CopyProxyHttpResponse(context, responseMessage, proxyCancellationToken);
74+
}
75+
}
76+
}
77+
catch (OperationCanceledException)
78+
{
79+
// If we're aborting because either the client disconnected, or the server
80+
// is shutting down, don't treat this as an error.
81+
return true;
82+
}
83+
catch (IOException)
84+
{
85+
// This kind of exception can also occur if a proxy read/write gets interrupted
86+
// due to the process shutting down.
87+
return true;
88+
}
89+
}
90+
91+
private static HttpRequestMessage CreateProxyHttpRequest(HttpContext context, Uri uri)
92+
{
93+
var request = context.Request;
94+
95+
var requestMessage = new HttpRequestMessage();
96+
var requestMethod = request.Method;
97+
if (!HttpMethods.IsGet(requestMethod) &&
98+
!HttpMethods.IsHead(requestMethod) &&
99+
!HttpMethods.IsDelete(requestMethod) &&
100+
!HttpMethods.IsTrace(requestMethod))
101+
{
102+
var streamContent = new StreamContent(request.Body);
103+
requestMessage.Content = streamContent;
104+
}
105+
106+
// Copy the request headers
107+
foreach (var header in request.Headers)
108+
{
109+
if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()) && requestMessage.Content != null)
110+
{
111+
requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
112+
}
113+
}
114+
115+
requestMessage.Headers.Host = uri.Authority;
116+
requestMessage.RequestUri = uri;
117+
requestMessage.Method = new HttpMethod(request.Method);
118+
119+
return requestMessage;
120+
}
121+
122+
private static Task<HttpResponseMessage> SendProxyHttpRequest(HttpContext context, HttpClient httpClient, HttpRequestMessage requestMessage, CancellationToken cancellationToken)
123+
{
124+
if (requestMessage == null)
125+
{
126+
throw new ArgumentNullException(nameof(requestMessage));
127+
}
128+
129+
return httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
130+
}
131+
132+
private static async Task<bool> CopyProxyHttpResponse(HttpContext context, HttpResponseMessage responseMessage, CancellationToken cancellationToken)
133+
{
134+
if (responseMessage.StatusCode == HttpStatusCode.NotFound)
135+
{
136+
// Let some other middleware handle this
137+
return false;
138+
}
139+
140+
// We can handle this
141+
context.Response.StatusCode = (int)responseMessage.StatusCode;
142+
foreach (var header in responseMessage.Headers)
143+
{
144+
context.Response.Headers[header.Key] = header.Value.ToArray();
145+
}
146+
147+
foreach (var header in responseMessage.Content.Headers)
148+
{
149+
context.Response.Headers[header.Key] = header.Value.ToArray();
150+
}
151+
152+
// SendAsync removes chunking from the response. This removes the header so it doesn't expect a chunked response.
153+
context.Response.Headers.Remove("transfer-encoding");
154+
155+
using (var responseStream = await responseMessage.Content.ReadAsStreamAsync())
156+
{
157+
await responseStream.CopyToAsync(context.Response.Body, StreamCopyBufferSize, cancellationToken);
158+
}
159+
160+
return true;
161+
}
162+
163+
private static Uri ToWebSocketScheme(Uri uri)
164+
{
165+
if (uri == null)
166+
{
167+
throw new ArgumentNullException(nameof(uri));
168+
}
169+
170+
var uriBuilder = new UriBuilder(uri);
171+
if (string.Equals(uriBuilder.Scheme, "https", StringComparison.OrdinalIgnoreCase))
172+
{
173+
uriBuilder.Scheme = "wss";
174+
}
175+
else if (string.Equals(uriBuilder.Scheme, "http", StringComparison.OrdinalIgnoreCase))
176+
{
177+
uriBuilder.Scheme = "ws";
178+
}
179+
180+
return uriBuilder.Uri;
181+
}
182+
183+
private static async Task<bool> AcceptProxyWebSocketRequest(HttpContext context, Uri destinationUri, CancellationToken cancellationToken)
184+
{
185+
if (context == null)
186+
{
187+
throw new ArgumentNullException(nameof(context));
188+
}
189+
if (destinationUri == null)
190+
{
191+
throw new ArgumentNullException(nameof(destinationUri));
192+
}
193+
if (!context.WebSockets.IsWebSocketRequest)
194+
{
195+
throw new InvalidOperationException();
196+
}
197+
198+
using (var client = new ClientWebSocket())
199+
{
200+
foreach (var headerEntry in context.Request.Headers)
201+
{
202+
if (!NotForwardedWebSocketHeaders.Contains(headerEntry.Key, StringComparer.OrdinalIgnoreCase))
203+
{
204+
client.Options.SetRequestHeader(headerEntry.Key, headerEntry.Value);
205+
}
206+
}
207+
208+
try
209+
{
210+
// Note that this is not really good enough to make Websockets work with
211+
// Angular CLI middleware. For some reason, ConnectAsync takes over 1 second,
212+
// by which time the logic in SockJS has already timed out and made it fall
213+
// back on some other transport (xhr_streaming, usually). This is not a problem,
214+
// because the transport fallback logic works correctly and doesn't surface any
215+
// errors, but it would be better if ConnectAsync was fast enough and the
216+
// initial Websocket transport could actually be used.
217+
await client.ConnectAsync(destinationUri, cancellationToken);
218+
}
219+
catch (WebSocketException)
220+
{
221+
context.Response.StatusCode = 400;
222+
return false;
223+
}
224+
225+
using (var server = await context.WebSockets.AcceptWebSocketAsync(client.SubProtocol))
226+
{
227+
var bufferSize = DefaultWebSocketBufferSize;
228+
await Task.WhenAll(
229+
PumpWebSocket(client, server, bufferSize, cancellationToken),
230+
PumpWebSocket(server, client, bufferSize, cancellationToken));
231+
}
232+
233+
return true;
234+
}
235+
}
236+
237+
private static async Task PumpWebSocket(WebSocket source, WebSocket destination, int bufferSize, CancellationToken cancellationToken)
238+
{
239+
if (bufferSize <= 0)
240+
{
241+
throw new ArgumentOutOfRangeException(nameof(bufferSize));
242+
}
243+
244+
var buffer = new byte[bufferSize];
245+
246+
while (true)
247+
{
248+
var result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
249+
250+
if (result.MessageType == WebSocketMessageType.Close)
251+
{
252+
if (destination.State == WebSocketState.Open || destination.State == WebSocketState.CloseReceived)
253+
{
254+
await destination.CloseOutputAsync(source.CloseStatus.Value, source.CloseStatusDescription, cancellationToken);
255+
}
256+
257+
return;
258+
}
259+
260+
await destination.SendAsync(new ArraySegment<byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, cancellationToken);
261+
}
262+
}
263+
}
264+
}

0 commit comments

Comments
 (0)