mirror of
https://github.com/aspnet/JavaScriptServices.git
synced 2025-12-22 17:47:53 +00:00
When a SPA dev server (or prerendering build) takes too long to start up, only fail current request, not future requests. Fixes #1447
This commit is contained in:
@@ -20,7 +20,6 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
|
|||||||
public class AngularCliBuilder : ISpaPrerendererBuilder
|
public class AngularCliBuilder : ISpaPrerendererBuilder
|
||||||
{
|
{
|
||||||
private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
|
private static TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
|
||||||
private static TimeSpan BuildTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter
|
|
||||||
|
|
||||||
private readonly string _npmScriptName;
|
private readonly string _npmScriptName;
|
||||||
|
|
||||||
@@ -63,8 +62,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await npmScriptRunner.StdOut.WaitForMatch(
|
await npmScriptRunner.StdOut.WaitForMatch(
|
||||||
new Regex("Date", RegexOptions.None, RegexMatchTimeout),
|
new Regex("Date", RegexOptions.None, RegexMatchTimeout));
|
||||||
BuildTimeout);
|
|
||||||
}
|
}
|
||||||
catch (EndOfStreamException ex)
|
catch (EndOfStreamException ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using System.Text.RegularExpressions;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using Microsoft.AspNetCore.SpaServices.Extensions.Util;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.SpaServices.AngularCli
|
namespace Microsoft.AspNetCore.SpaServices.AngularCli
|
||||||
{
|
{
|
||||||
@@ -49,7 +50,15 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
|
|||||||
var targetUriTask = angularCliServerInfoTask.ContinueWith(
|
var targetUriTask = angularCliServerInfoTask.ContinueWith(
|
||||||
task => new UriBuilder("http", "localhost", task.Result.Port).Uri);
|
task => new UriBuilder("http", "localhost", task.Result.Port).Uri);
|
||||||
|
|
||||||
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask);
|
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () =>
|
||||||
|
{
|
||||||
|
// On each request, we create a separate startup task with its own timeout. That way, even if
|
||||||
|
// the first request times out, subsequent requests could still work.
|
||||||
|
return targetUriTask.WithTimeout(StartupTimeout,
|
||||||
|
$"The Angular CLI process did not start listening for requests " +
|
||||||
|
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
|
||||||
|
$"Check the log output for error information.");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
|
private static async Task<AngularCliServerInfo> StartAngularCliServerAsync(
|
||||||
@@ -68,8 +77,7 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
|
openBrowserLine = await npmScriptRunner.StdOut.WaitForMatch(
|
||||||
new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout),
|
new Regex("open your browser on (http\\S+)", RegexOptions.None, RegexMatchTimeout));
|
||||||
StartupTimeout);
|
|
||||||
}
|
}
|
||||||
catch (EndOfStreamException ex)
|
catch (EndOfStreamException ex)
|
||||||
{
|
{
|
||||||
@@ -78,13 +86,6 @@ namespace Microsoft.AspNetCore.SpaServices.AngularCli
|
|||||||
$"Angular CLI was listening for requests. The error output was: " +
|
$"Angular CLI was listening for requests. The error output was: " +
|
||||||
$"{stdErrReader.ReadAsString()}", ex);
|
$"{stdErrReader.ReadAsString()}", ex);
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"The Angular CLI process did not start listening for requests " +
|
|
||||||
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
|
|
||||||
$"Check the log output for error information.", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var uri = new Uri(openBrowserLine.Groups[1].Value);
|
var uri = new Uri(openBrowserLine.Groups[1].Value);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Http.Features;
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
using Microsoft.AspNetCore.NodeServices;
|
using Microsoft.AspNetCore.NodeServices;
|
||||||
using Microsoft.AspNetCore.SpaServices;
|
using Microsoft.AspNetCore.SpaServices;
|
||||||
|
using Microsoft.AspNetCore.SpaServices.Extensions.Util;
|
||||||
using Microsoft.AspNetCore.SpaServices.Prerendering;
|
using Microsoft.AspNetCore.SpaServices.Prerendering;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Net.Http.Headers;
|
using Microsoft.Net.Http.Headers;
|
||||||
@@ -23,6 +24,8 @@ namespace Microsoft.AspNetCore.Builder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SpaPrerenderingExtensions
|
public static class SpaPrerenderingExtensions
|
||||||
{
|
{
|
||||||
|
private static TimeSpan BuildTimeout = TimeSpan.FromSeconds(50); // Note that the HTTP request itself by default times out after 60s, so you only get useful error information if this is shorter
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enables server-side prerendering middleware for a Single Page Application.
|
/// Enables server-side prerendering middleware for a Single Page Application.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -85,9 +88,15 @@ namespace Microsoft.AspNetCore.Builder
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we're building on demand, wait for that to finish, or raise any build errors
|
// If we're building on demand, wait for that to finish, or raise any build errors
|
||||||
if (buildOnDemandTask != null)
|
if (buildOnDemandTask != null && !buildOnDemandTask.IsCompleted)
|
||||||
{
|
{
|
||||||
await buildOnDemandTask;
|
// For better debuggability, create a per-request timeout that makes it clear if the
|
||||||
|
// prerendering builder took too long for this request, but without aborting the
|
||||||
|
// underlying build task so that subsequent requests could still work.
|
||||||
|
await buildOnDemandTask.WithTimeout(BuildTimeout,
|
||||||
|
$"The prerendering build process did not complete within the " +
|
||||||
|
$"timeout period of {BuildTimeout.Seconds} seconds. " +
|
||||||
|
$"Check the log output for error information.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// It's no good if we try to return a 304. We need to capture the actual
|
// It's no good if we try to return a 304. We need to capture the actual
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ namespace Microsoft.AspNetCore.Builder
|
|||||||
{
|
{
|
||||||
UseProxyToSpaDevelopmentServer(
|
UseProxyToSpaDevelopmentServer(
|
||||||
spaBuilder,
|
spaBuilder,
|
||||||
Task.FromResult(baseUri));
|
() => Task.FromResult(baseUri));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -54,10 +54,10 @@ namespace Microsoft.AspNetCore.Builder
|
|||||||
/// development. Do not enable this middleware in production applications.
|
/// development. Do not enable this middleware in production applications.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
|
/// <param name="spaBuilder">The <see cref="ISpaBuilder"/>.</param>
|
||||||
/// <param name="baseUriTask">A <see cref="Task"/> that resolves with the target base URI to which requests should be proxied.</param>
|
/// <param name="baseUriTaskFactory">A callback that will be invoked on each request to supply a <see cref="Task"/> that resolves with the target base URI to which requests should be proxied.</param>
|
||||||
public static void UseProxyToSpaDevelopmentServer(
|
public static void UseProxyToSpaDevelopmentServer(
|
||||||
this ISpaBuilder spaBuilder,
|
this ISpaBuilder spaBuilder,
|
||||||
Task<Uri> baseUriTask)
|
Func<Task<Uri>> baseUriTaskFactory)
|
||||||
{
|
{
|
||||||
var applicationBuilder = spaBuilder.ApplicationBuilder;
|
var applicationBuilder = spaBuilder.ApplicationBuilder;
|
||||||
var applicationStoppingToken = GetStoppingToken(applicationBuilder);
|
var applicationStoppingToken = GetStoppingToken(applicationBuilder);
|
||||||
@@ -72,11 +72,11 @@ namespace Microsoft.AspNetCore.Builder
|
|||||||
var neverTimeOutHttpClient =
|
var neverTimeOutHttpClient =
|
||||||
SpaProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan);
|
SpaProxy.CreateHttpClientForProxy(Timeout.InfiniteTimeSpan);
|
||||||
|
|
||||||
// Proxy all requests into the Angular CLI server
|
// Proxy all requests to the SPA development server
|
||||||
applicationBuilder.Use(async (context, next) =>
|
applicationBuilder.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
var didProxyRequest = await SpaProxy.PerformProxyRequest(
|
var didProxyRequest = await SpaProxy.PerformProxyRequest(
|
||||||
context, neverTimeOutHttpClient, baseUriTask, applicationStoppingToken,
|
context, neverTimeOutHttpClient, baseUriTaskFactory(), applicationStoppingToken,
|
||||||
proxy404s: true);
|
proxy404s: true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using System.IO;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.SpaServices.Extensions.Util;
|
||||||
|
|
||||||
namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
|
namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
|
||||||
{
|
{
|
||||||
@@ -48,7 +49,15 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
|
|||||||
var targetUriTask = portTask.ContinueWith(
|
var targetUriTask = portTask.ContinueWith(
|
||||||
task => new UriBuilder("http", "localhost", task.Result).Uri);
|
task => new UriBuilder("http", "localhost", task.Result).Uri);
|
||||||
|
|
||||||
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, targetUriTask);
|
SpaProxyingExtensions.UseProxyToSpaDevelopmentServer(spaBuilder, () =>
|
||||||
|
{
|
||||||
|
// On each request, we create a separate startup task with its own timeout. That way, even if
|
||||||
|
// the first request times out, subsequent requests could still work.
|
||||||
|
return targetUriTask.WithTimeout(StartupTimeout,
|
||||||
|
$"The create-react-app server did not start listening for requests " +
|
||||||
|
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
|
||||||
|
$"Check the log output for error information.");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<int> StartCreateReactAppServerAsync(
|
private static async Task<int> StartCreateReactAppServerAsync(
|
||||||
@@ -75,8 +84,7 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
|
|||||||
// no compiler warnings. So instead of waiting for that, consider it ready as soon
|
// no compiler warnings. So instead of waiting for that, consider it ready as soon
|
||||||
// as it starts listening for requests.
|
// as it starts listening for requests.
|
||||||
await npmScriptRunner.StdOut.WaitForMatch(
|
await npmScriptRunner.StdOut.WaitForMatch(
|
||||||
new Regex("Starting the development server", RegexOptions.None, RegexMatchTimeout),
|
new Regex("Starting the development server", RegexOptions.None, RegexMatchTimeout));
|
||||||
StartupTimeout);
|
|
||||||
}
|
}
|
||||||
catch (EndOfStreamException ex)
|
catch (EndOfStreamException ex)
|
||||||
{
|
{
|
||||||
@@ -85,13 +93,6 @@ namespace Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer
|
|||||||
$"create-react-app server was listening for requests. The error output was: " +
|
$"create-react-app server was listening for requests. The error output was: " +
|
||||||
$"{stdErrReader.ReadAsString()}", ex);
|
$"{stdErrReader.ReadAsString()}", ex);
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException ex)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"The create-react-app server did not start listening for requests " +
|
|
||||||
$"within the timeout period of {StartupTimeout.Seconds} seconds. " +
|
|
||||||
$"Check the log output for error information.", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return portNumber;
|
return portNumber;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ namespace Microsoft.AspNetCore.NodeServices.Util
|
|||||||
Task.Factory.StartNew(Run);
|
Task.Factory.StartNew(Run);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Match> WaitForMatch(Regex regex, TimeSpan timeout = default)
|
public Task<Match> WaitForMatch(Regex regex)
|
||||||
{
|
{
|
||||||
var tcs = new TaskCompletionSource<Match>();
|
var tcs = new TaskCompletionSource<Match>();
|
||||||
var completionLock = new object();
|
var completionLock = new object();
|
||||||
@@ -72,15 +72,6 @@ namespace Microsoft.AspNetCore.NodeServices.Util
|
|||||||
OnReceivedLine += onReceivedLineHandler;
|
OnReceivedLine += onReceivedLineHandler;
|
||||||
OnStreamClosed += onStreamClosedHandler;
|
OnStreamClosed += onStreamClosedHandler;
|
||||||
|
|
||||||
if (timeout != default)
|
|
||||||
{
|
|
||||||
var timeoutToken = new CancellationTokenSource(timeout);
|
|
||||||
timeoutToken.Token.Register(() =>
|
|
||||||
{
|
|
||||||
ResolveIfStillPending(() => tcs.SetCanceled());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return tcs.Task;
|
return tcs.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.SpaServices.Extensions.Util
|
||||||
|
{
|
||||||
|
internal static class TaskTimeoutExtensions
|
||||||
|
{
|
||||||
|
public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message)
|
||||||
|
{
|
||||||
|
if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
|
||||||
|
{
|
||||||
|
task.Wait(); // Allow any errors to propagate
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new TimeoutException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<T> WithTimeout<T>(this Task<T> task, TimeSpan timeoutDelay, string message)
|
||||||
|
{
|
||||||
|
if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
|
||||||
|
{
|
||||||
|
return task.Result;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new TimeoutException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user