diff --git a/README.md b/README.md index efe6e5a1..ed2f172c 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ This project is part of ASP.NET Core. You can find samples, documentation and ge This repo contains: * A set of NuGet/NPM packages that implement functionality for: - * Invoking arbitrary NPM packages at runtime from .NET code ([docs](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.NodeServices#simple-usage-example)) - * Server-side prerendering of SPA components ([docs](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.SpaServices#server-side-prerendering)) - * Webpack dev middleware ([docs](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.SpaServices#webpack-dev-middleware)) - * Hot module replacement (HMR) ([docs](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.SpaServices#webpack-hot-module-replacement)) - * Server-side and client-side routing integration ([docs](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.SpaServices#routing-helper-mapspafallbackroute)) + * Invoking arbitrary NPM packages at runtime from .NET code ([docs](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.NodeServices#simple-usage-example)) + * Server-side prerendering of SPA components ([docs](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.SpaServices#server-side-prerendering)) + * Webpack dev middleware ([docs](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.SpaServices#webpack-dev-middleware)) + * Hot module replacement (HMR) ([docs](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.SpaServices#webpack-hot-module-replacement)) + * Server-side and client-side routing integration ([docs](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.SpaServices#routing-helper-mapspafallbackroute)) * Server-side and client-side validation integration * "Cache priming" for Angular 2 apps * "Lazy loading" for Knockout apps @@ -36,13 +36,13 @@ If you have an existing ASP.NET Core application, or if you just want to use the * `Microsoft.AspNetCore.NodeServices` * This provides a fast and robust way for .NET code to run JavaScript on the server inside a Node.js environment. You can use this to consume arbitrary functionality from NPM packages at runtime in your ASP.NET Core app. * Most applications developers don't need to use this directly, but you can do so if you want to implement your own functionality that involves calling Node.js code from .NET at runtime. - * Find [documentation and usage examples here](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.NodeServices#microsoftaspnetcorenodeservices). + * Find [documentation and usage examples here](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.NodeServices#microsoftaspnetcorenodeservices). * `Microsoft.AspNetCore.SpaServices` * This provides infrastructure that's generally useful when building Single Page Applications (SPAs) with technologies such as Angular 2 or React (for example, server-side prerendering and webpack middleware). Internally, it uses the `NodeServices` package to implement its features. - * Find [documentation and usage examples here](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.SpaServices#microsoftaspnetcorespaservices). + * Find [documentation and usage examples here](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.SpaServices#microsoftaspnetcorespaservices). * `Microsoft.AspNetCore.AngularServices` * This builds on the `SpaServices` package and includes features specific to Angular 2. Currently, this includes validation helpers and a "cache priming" feature, which let you pre-evaluate ajax requests on the server so that client-side code doesn't need to make network calls once it's loaded. - * The code is [here](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.AngularServices), and you'll find a usage example for [the validation helper here](https://github.com/aspnet/JavaScriptServices/blob/master/samples/angular/MusicStore/wwwroot/ng-app/components/admin/album-edit/album-edit.ts), and for the [cache priming here](https://github.com/aspnet/JavaScriptServices/blob/master/samples/angular/MusicStore/Views/Home/Index.cshtml#L7-8). Full docs are to be written. + * The code is [here](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.AngularServices), and you'll find a usage example for [the validation helper here](https://github.com/aspnet/JavaScriptServices/blob/dev/samples/angular/MusicStore/wwwroot/ng-app/components/admin/album-edit/album-edit.ts), and for the [cache priming here](https://github.com/aspnet/JavaScriptServices/blob/dev/samples/angular/MusicStore/Views/Home/Index.cshtml#L7-8). Full docs are to be written. There was previously a `Microsoft.AspNetCore.ReactServices` but this is not currently needed - all applicable functionality is in `Microsoft.AspNetCore.SpaServices`, because it's sufficiently general. We might add a new `Microsoft.AspNetCore.ReactServices` package in the future if new React-specific requirements emerge. @@ -50,9 +50,9 @@ If you want to build a helper library for some other SPA framework, you can do s ## Samples and templates -Inside this repo, [the `templates` directory](https://github.com/aspnet/JavaScriptServices/tree/master/templates) contains the application starting points that the `aspnetcore-spa` generator emits. If you want, you can clone this repo and run those applications directly. But it's easier to [use the Yeoman tool to run the generator](http://blog.stevensanderson.com/2016/05/02/angular2-react-knockout-apps-on-aspnet-core/). +Inside this repo, [the `templates` directory](https://github.com/aspnet/JavaScriptServices/tree/dev/templates) contains the application starting points that the `aspnetcore-spa` generator emits. If you want, you can clone this repo and run those applications directly. But it's easier to [use the Yeoman tool to run the generator](http://blog.stevensanderson.com/2016/05/02/angular2-react-knockout-apps-on-aspnet-core/). -Also in this repo, [the `samples` directory](https://github.com/aspnet/JavaScriptServices/tree/master/samples) contains examples of using the JavaScript services family of packages with Angular 2 and React, plus examples of standalone `NodeServices` usage for runtime code transpilation and image processing. +Also in this repo, [the `samples` directory](https://github.com/aspnet/JavaScriptServices/tree/dev/samples) contains examples of using the JavaScript services family of packages with Angular 2 and React, plus examples of standalone `NodeServices` usage for runtime code transpilation and image processing. **To run the samples:** diff --git a/samples/angular/MusicStore/Startup.cs b/samples/angular/MusicStore/Startup.cs index 3d5e2636..a367d28f 100755 --- a/samples/angular/MusicStore/Startup.cs +++ b/samples/angular/MusicStore/Startup.cs @@ -45,14 +45,17 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy("app-ManageStore", new AuthorizationPolicyBuilder().RequireClaim("app-ManageStore", "Allowed").Build()); }); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); + Mapper.Initialize(cfg => + { + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/samples/angular/MusicStore/project.json b/samples/angular/MusicStore/project.json index d341cf13..ac5a9387 100755 --- a/samples/angular/MusicStore/project.json +++ b/samples/angular/MusicStore/project.json @@ -29,7 +29,7 @@ "Microsoft.Extensions.Logging.Debug": "1.0.0", "Microsoft.EntityFrameworkCore.SQLite": "1.0.0", "Microsoft.AspNetCore.AngularServices": "1.0.0-*", - "AutoMapper": "4.1.1" + "AutoMapper": "5.0.2" }, "frameworks": { "netcoreapp1.0": { diff --git a/samples/misc/LatencyTest/Program.cs b/samples/misc/LatencyTest/Program.cs index 855402b4..2087d29e 100755 --- a/samples/misc/LatencyTest/Program.cs +++ b/samples/misc/LatencyTest/Program.cs @@ -12,21 +12,21 @@ namespace ConsoleApplication public class Program { public static void Main(string[] args) { - using (var nodeServices = CreateNodeServices(Configuration.DefaultNodeHostingModel)) { + using (var nodeServices = CreateNodeServices(NodeServicesOptions.DefaultNodeHostingModel)) { MeasureLatency(nodeServices).Wait(); } } private static async Task MeasureLatency(INodeServices nodeServices) { // Ensure the connection is open, so we can measure per-request timings below - var response = await nodeServices.Invoke("latencyTest", "C#"); + var response = await nodeServices.InvokeAsync("latencyTest", "C#"); Console.WriteLine(response); // Now perform a series of requests, capturing the time taken const int requestCount = 100; var watch = Stopwatch.StartNew(); for (var i = 0; i < requestCount; i++) { - await nodeServices.Invoke("latencyTest", "C#"); + await nodeServices.InvokeAsync("latencyTest", "C#"); } // Display results diff --git a/samples/misc/NodeServicesExamples/Controllers/ResizeImage.cs b/samples/misc/NodeServicesExamples/Controllers/ResizeImage.cs index e0f498e2..43f45df3 100644 --- a/samples/misc/NodeServicesExamples/Controllers/ResizeImage.cs +++ b/samples/misc/NodeServicesExamples/Controllers/ResizeImage.cs @@ -46,7 +46,7 @@ public async Task Index(string imagePath, int maxWidth, int maxHe } // Invoke Node and pipe the result to the response - var imageStream = await _nodeServices.Invoke( + var imageStream = await _nodeServices.InvokeAsync( "./Node/resizeImage", fileInfo.PhysicalPath, mimeType, diff --git a/samples/misc/NodeServicesExamples/Startup.cs b/samples/misc/NodeServicesExamples/Startup.cs index cec3093a..1c38c60a 100755 --- a/samples/misc/NodeServicesExamples/Startup.cs +++ b/samples/misc/NodeServicesExamples/Startup.cs @@ -30,7 +30,7 @@ public void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory, IHo if (requestPath.StartsWith("/js/") && requestPath.EndsWith(".js")) { var fileInfo = env.WebRootFileProvider.GetFileInfo(requestPath); if (fileInfo.Exists) { - var transpiled = await nodeServices.Invoke("./Node/transpilation.js", fileInfo.PhysicalPath, requestPath); + var transpiled = await nodeServices.InvokeAsync("./Node/transpilation.js", fileInfo.PhysicalPath, requestPath); await context.Response.WriteAsync(transpiled); return; } diff --git a/samples/misc/Webpack/ActionResults/PrerenderResult.cs b/samples/misc/Webpack/ActionResults/PrerenderResult.cs new file mode 100644 index 00000000..b8e7ec15 --- /dev/null +++ b/samples/misc/Webpack/ActionResults/PrerenderResult.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.NodeServices; +using Microsoft.AspNetCore.SpaServices.Prerendering; +using Microsoft.Extensions.DependencyInjection; + +namespace Webpack.ActionResults +{ + // This is an example of how you could invoke the prerendering API from an ActionResult, so as to + // prerender a SPA component as the entire response page (instead of injecting the SPA component + // into a Razor view's output) + public class PrerenderResult : ActionResult + { + private JavaScriptModuleExport _moduleExport; + private object _dataToSupply; + + public PrerenderResult(JavaScriptModuleExport moduleExport, object dataToSupply = null) + { + _moduleExport = moduleExport; + _dataToSupply = dataToSupply; + } + + public override async Task ExecuteResultAsync(ActionContext context) + { + var nodeServices = context.HttpContext.RequestServices.GetRequiredService(); + var hostEnv = context.HttpContext.RequestServices.GetRequiredService(); + var applicationBasePath = hostEnv.ContentRootPath; + var request = context.HttpContext.Request; + var response = context.HttpContext.Response; + + var prerenderedHtml = await Prerenderer.RenderToString( + applicationBasePath, + nodeServices, + _moduleExport, + request.GetEncodedUrl(), + request.Path + request.QueryString.Value, + _dataToSupply + ); + + response.ContentType = "text/html"; + await response.WriteAsync(prerenderedHtml.Html); + } + } +} \ No newline at end of file diff --git a/samples/misc/Webpack/ActionResults/PrerenderResultExtensions.cs b/samples/misc/Webpack/ActionResults/PrerenderResultExtensions.cs new file mode 100644 index 00000000..926e1149 --- /dev/null +++ b/samples/misc/Webpack/ActionResults/PrerenderResultExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SpaServices.Prerendering; + +namespace Webpack.ActionResults +{ + public static class PrerenderResultExtensions + { + public static PrerenderResult Prerender(this ControllerBase controller, JavaScriptModuleExport exportToPrerender, object dataToSupply = null) + { + return new PrerenderResult(exportToPrerender, dataToSupply); + } + } +} diff --git a/samples/misc/Webpack/Clientside/PrerenderingSample.ts b/samples/misc/Webpack/Clientside/PrerenderingSample.ts new file mode 100644 index 00000000..143ddd04 --- /dev/null +++ b/samples/misc/Webpack/Clientside/PrerenderingSample.ts @@ -0,0 +1,17 @@ +export default function (params: any): Promise<{ html: string, globals?: any }> { + return new Promise((resolve, reject) => { + + // Here, you could put any logic that synchronously or asynchronously prerenders + // your SPA components. For example, see the boot-server.ts files in the Angular2Spa + // and ReactReduxSpa templates for ways to prerender Angular 2 and React components. + // + // If you wanted, you could use a property on the 'params.data' object to specify + // which SPA component or template to render. + + const html = ` +

Hello

+ It works! You passed ${ JSON.stringify(params.data) } + and are currently requesting ${ params.location.path }`; + resolve({ html }); + }); +}; diff --git a/samples/misc/Webpack/Controllers/FullPagePrerenderingController.cs b/samples/misc/Webpack/Controllers/FullPagePrerenderingController.cs new file mode 100644 index 00000000..48935459 --- /dev/null +++ b/samples/misc/Webpack/Controllers/FullPagePrerenderingController.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SpaServices.Prerendering; +using Webpack.ActionResults; + +namespace Webpack.Controllers +{ + // This sample shows how you could invoke the prerendering APIs directly from an MVC + // action result. + public class FullPagePrerenderingController : Controller + { + private static JavaScriptModuleExport BootModule = new JavaScriptModuleExport("Clientside/PrerenderingSample") + { + // Because the boot module is written in TypeScript, we need to specify a webpack + // config so it can be built. If it was written in JavaScript, this would not be needed. + WebpackConfig = "webpack.config.js" + }; + + public IActionResult Index() + { + var dataToSupply = new { nowTime = DateTime.Now.Ticks }; + return this.Prerender(BootModule, dataToSupply); + } + } +} diff --git a/samples/misc/Webpack/Startup.cs b/samples/misc/Webpack/Startup.cs index a19fcc1a..c81e5c0d 100755 --- a/samples/misc/Webpack/Startup.cs +++ b/samples/misc/Webpack/Startup.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using System.IO; +using Microsoft.AspNetCore.NodeServices; namespace Webpack { @@ -14,6 +15,7 @@ public class Startup public void ConfigureServices(IServiceCollection services) { services.AddMvc(); + services.AddNodeServices(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/samples/misc/Webpack/Views/Home/Index.cshtml b/samples/misc/Webpack/Views/Home/Index.cshtml index 7828ec19..365dfaad 100755 --- a/samples/misc/Webpack/Views/Home/Index.cshtml +++ b/samples/misc/Webpack/Views/Home/Index.cshtml @@ -5,6 +5,9 @@

Hello

Hi there. Enter some text: +
+See also: Full-page prerendering example + @section scripts { } diff --git a/samples/misc/Webpack/package.json b/samples/misc/Webpack/package.json index 39bacb81..e894a076 100644 --- a/samples/misc/Webpack/package.json +++ b/samples/misc/Webpack/package.json @@ -2,15 +2,18 @@ "name": "Webpack", "version": "0.0.0", "devDependencies": { - "aspnet-webpack": "^1.0.3", "css-loader": "^0.23.1", "extendify": "^1.0.0", "extract-text-webpack-plugin": "^1.0.1", "less": "^2.6.0", "less-loader": "^2.2.2", "style-loader": "^0.13.0", - "ts-loader": "^0.8.1", - "typescript": "^1.7.5", "webpack-hot-middleware": "^2.7.1" + }, + "dependencies": { + "aspnet-webpack": "^1.0.3", + "aspnet-prerendering": "^1.0.4", + "ts-loader": "^0.8.1", + "typescript": "^1.7.5" } } diff --git a/samples/misc/Webpack/tsconfig.json b/samples/misc/Webpack/tsconfig.json index 5cbeb866..bb577c55 100644 --- a/samples/misc/Webpack/tsconfig.json +++ b/samples/misc/Webpack/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "moduleResolution": "node", - "target": "es5", + "module": "commonjs", + "target": "es6", "jsx": "preserve", "sourceMap": true }, diff --git a/samples/react/MusicStore/ReactApp/store/AlbumDetails.ts b/samples/react/MusicStore/ReactApp/store/AlbumDetails.ts index 247c947a..ad3f458e 100644 --- a/samples/react/MusicStore/ReactApp/store/AlbumDetails.ts +++ b/samples/react/MusicStore/ReactApp/store/AlbumDetails.ts @@ -1,4 +1,4 @@ -import { fetch } from 'domain-task/fetch'; +import { fetch, addTask } from 'domain-task'; import { typeName, isActionType, Action, Reducer } from 'redux-typed'; import { ActionCreator } from './'; import { Genre } from './GenreList'; @@ -51,7 +51,7 @@ export const actionCreators = { requestAlbumDetails: (albumId: number): ActionCreator => (dispatch, getState) => { // Only load if it's not already loaded (or currently being loaded) if (albumId !== getState().albumDetails.requestedAlbumId) { - fetch(`/api/albums/${ albumId }`) + let fetchTask = fetch(`/api/albums/${ albumId }`) .then(results => results.json()) .then(album => { // Only replace state if it's still the most recent request @@ -60,6 +60,7 @@ export const actionCreators = { } }); + addTask(fetchTask); // Ensure server-side prerendering waits for this to complete dispatch(new RequestAlbumDetails(albumId)); } } diff --git a/samples/react/MusicStore/ReactApp/store/FeaturedAlbums.ts b/samples/react/MusicStore/ReactApp/store/FeaturedAlbums.ts index a3d13b15..f9e78282 100644 --- a/samples/react/MusicStore/ReactApp/store/FeaturedAlbums.ts +++ b/samples/react/MusicStore/ReactApp/store/FeaturedAlbums.ts @@ -1,4 +1,4 @@ -import { fetch } from 'domain-task/fetch'; +import { fetch, addTask } from 'domain-task'; import { typeName, isActionType, Action, Reducer } from 'redux-typed'; import { ActionCreator } from './'; @@ -39,10 +39,11 @@ class ReceiveFeaturedAlbums extends Action { export const actionCreators = { requestFeaturedAlbums: (): ActionCreator => (dispatch, getState) => { if (!getState().featuredAlbums.isLoaded) { - fetch('/api/albums/mostPopular') + let fetchTask = fetch('/api/albums/mostPopular') .then(results => results.json()) .then(albums => dispatch(new ReceiveFeaturedAlbums(albums))); + addTask(fetchTask); // Ensure server-side prerendering waits for this to complete return dispatch(new RequestFeaturedAlbums()); } } diff --git a/samples/react/MusicStore/ReactApp/store/GenreDetails.ts b/samples/react/MusicStore/ReactApp/store/GenreDetails.ts index 2a5f8ff0..24848a10 100644 --- a/samples/react/MusicStore/ReactApp/store/GenreDetails.ts +++ b/samples/react/MusicStore/ReactApp/store/GenreDetails.ts @@ -1,4 +1,4 @@ -import { fetch } from 'domain-task/fetch'; +import { fetch, addTask } from 'domain-task'; import { typeName, isActionType, Action, Reducer } from 'redux-typed'; import { ActionCreator } from './'; import { Album } from './FeaturedAlbums'; @@ -39,7 +39,7 @@ export const actionCreators = { requestGenreDetails: (genreId: number): ActionCreator => (dispatch, getState) => { // Only load if it's not already loaded (or currently being loaded) if (genreId !== getState().genreDetails.requestedGenreId) { - fetch(`/api/genres/${ genreId }/albums`) + let fetchTask = fetch(`/api/genres/${ genreId }/albums`) .then(results => results.json()) .then(albums => { // Only replace state if it's still the most recent request @@ -48,6 +48,7 @@ export const actionCreators = { } }); + addTask(fetchTask); // Ensure server-side prerendering waits for this to complete dispatch(new RequestGenreDetails(genreId)); } } diff --git a/samples/react/MusicStore/ReactApp/store/GenreList.ts b/samples/react/MusicStore/ReactApp/store/GenreList.ts index e24e32cd..8843f22e 100644 --- a/samples/react/MusicStore/ReactApp/store/GenreList.ts +++ b/samples/react/MusicStore/ReactApp/store/GenreList.ts @@ -1,4 +1,4 @@ -import { fetch } from 'domain-task/fetch'; +import { fetch, addTask } from 'domain-task'; import { typeName, isActionType, Action, Reducer } from 'redux-typed'; import { ActionCreator } from './'; @@ -34,9 +34,10 @@ class ReceiveGenresList extends Action { export const actionCreators = { requestGenresList: (): ActionCreator => (dispatch, getState) => { if (!getState().genreList.isLoaded) { - fetch('/api/genres') + let fetchTask = fetch('/api/genres') .then(results => results.json()) .then(genres => dispatch(new ReceiveGenresList(genres))); + addTask(fetchTask); // Ensure server-side prerendering waits for this to complete } } }; diff --git a/samples/react/MusicStore/Startup.cs b/samples/react/MusicStore/Startup.cs index 4ea3fec3..1bc6003d 100755 --- a/samples/react/MusicStore/Startup.cs +++ b/samples/react/MusicStore/Startup.cs @@ -47,14 +47,17 @@ public void ConfigureServices(IServiceCollection services) options.AddPolicy("app-ManageStore", new AuthorizationPolicyBuilder().RequireClaim("app-ManageStore", "Allowed").Build()); }); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); - Mapper.CreateMap(); + Mapper.Initialize(cfg => + { + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/samples/react/MusicStore/package.json b/samples/react/MusicStore/package.json index 448f1c9f..084f8ee9 100644 --- a/samples/react/MusicStore/package.json +++ b/samples/react/MusicStore/package.json @@ -25,7 +25,7 @@ "aspnet-webpack-react": "^1.0.1", "bootstrap": "^3.3.6", "domain-context": "^0.5.1", - "domain-task": "^1.0.0", + "domain-task": "^2.0.0", "history": "^2.0.0", "isomorphic-fetch": "^2.2.1", "memory-fs": "^0.3.0", diff --git a/samples/react/MusicStore/project.json b/samples/react/MusicStore/project.json index faebf7ac..6f5c801b 100755 --- a/samples/react/MusicStore/project.json +++ b/samples/react/MusicStore/project.json @@ -29,7 +29,7 @@ "Microsoft.Extensions.Logging.Debug": "1.0.0", "Microsoft.EntityFrameworkCore.SQLite": "1.0.0", "Microsoft.AspNetCore.ReactServices": "1.0.0-*", - "AutoMapper": "4.1.1" + "AutoMapper": "5.0.2" }, "frameworks": { "netcoreapp1.0": { diff --git a/samples/react/ReactGrid/package.json b/samples/react/ReactGrid/package.json index a2de71cd..123cc473 100644 --- a/samples/react/ReactGrid/package.json +++ b/samples/react/ReactGrid/package.json @@ -4,7 +4,7 @@ "dependencies": { "babel-core": "^6.4.5", "bootstrap": "^3.3.5", - "domain-task": "^1.0.0", + "domain-task": "^2.0.0", "formsy-react": "^0.17.0", "formsy-react-components": "^0.6.3", "griddle-react": "^0.3.1", diff --git a/src/Microsoft.AspNetCore.NodeServices/Configuration.cs b/src/Microsoft.AspNetCore.NodeServices/Configuration.cs deleted file mode 100644 index d24a7e33..00000000 --- a/src/Microsoft.AspNetCore.NodeServices/Configuration.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.AspNetCore.Hosting; - -namespace Microsoft.AspNetCore.NodeServices -{ - public static class Configuration - { - public const NodeHostingModel DefaultNodeHostingModel = NodeHostingModel.Http; - - private static readonly string[] DefaultWatchFileExtensions = {".js", ".jsx", ".ts", ".tsx", ".json", ".html"}; - private static readonly NodeServicesOptions DefaultOptions = new NodeServicesOptions - { - HostingModel = DefaultNodeHostingModel, - WatchFileExtensions = DefaultWatchFileExtensions - }; - - public static void AddNodeServices(this IServiceCollection serviceCollection) - => AddNodeServices(serviceCollection, DefaultOptions); - - public static void AddNodeServices(this IServiceCollection serviceCollection, NodeServicesOptions options) - { - serviceCollection.AddSingleton(typeof(INodeServices), serviceProvider => - { - var hostEnv = serviceProvider.GetRequiredService(); - if (string.IsNullOrEmpty(options.ProjectPath)) - { - options.ProjectPath = hostEnv.ContentRootPath; - } - - return CreateNodeServices(options); - }); - } - - public static INodeServices CreateNodeServices(NodeServicesOptions options) - { - var watchFileExtensions = options.WatchFileExtensions ?? DefaultWatchFileExtensions; - switch (options.HostingModel) - { - case NodeHostingModel.Http: - return new HttpNodeInstance(options.ProjectPath, /* port */ 0, watchFileExtensions); - case NodeHostingModel.Socket: - return new SocketNodeInstance(options.ProjectPath, watchFileExtensions); - default: - throw new ArgumentException("Unknown hosting model: " + options.HostingModel); - } - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/Configuration/Configuration.cs b/src/Microsoft.AspNetCore.NodeServices/Configuration/Configuration.cs new file mode 100644 index 00000000..bbae3d29 --- /dev/null +++ b/src/Microsoft.AspNetCore.NodeServices/Configuration/Configuration.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.NodeServices.HostingModels; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; + +namespace Microsoft.AspNetCore.NodeServices +{ + public static class Configuration + { + const string LogCategoryName = "Microsoft.AspNetCore.NodeServices"; + + public static void AddNodeServices(this IServiceCollection serviceCollection) + => AddNodeServices(serviceCollection, new NodeServicesOptions()); + + public static void AddNodeServices(this IServiceCollection serviceCollection, NodeServicesOptions options) + { + serviceCollection.AddSingleton(typeof(INodeServices), serviceProvider => + { + // Since this instance is being created through DI, we can access the IHostingEnvironment + // to populate options.ProjectPath if it wasn't explicitly specified. + if (string.IsNullOrEmpty(options.ProjectPath)) + { + var hostEnv = serviceProvider.GetRequiredService(); + options.ProjectPath = hostEnv.ContentRootPath; + } + + // Likewise, if no logger was specified explicitly, we should use the one from DI. + // If it doesn't provide one, CreateNodeInstance will set up a default. + if (options.NodeInstanceOutputLogger == null) + { + var loggerFactory = serviceProvider.GetService(); + if (loggerFactory != null) + { + options.NodeInstanceOutputLogger = loggerFactory.CreateLogger(LogCategoryName); + } + } + + return new NodeServicesImpl(options, () => CreateNodeInstance(options)); + }); + } + + public static INodeServices CreateNodeServices(NodeServicesOptions options) + { + return new NodeServicesImpl(options, () => CreateNodeInstance(options)); + } + + private static INodeInstance CreateNodeInstance(NodeServicesOptions options) + { + // If you've specified no logger, fall back on a default console logger + var logger = options.NodeInstanceOutputLogger; + if (logger == null) + { + logger = new ConsoleLogger(LogCategoryName, null, false); + } + + if (options.NodeInstanceFactory != null) + { + // If you've explicitly supplied an INodeInstance factory, we'll use that. This is useful for + // custom INodeInstance implementations. + return options.NodeInstanceFactory(); + } + else + { + // Otherwise we'll construct the type of INodeInstance specified by the HostingModel property, + // which itself has a useful default value. + switch (options.HostingModel) + { + case NodeHostingModel.Http: + return new HttpNodeInstance(options.ProjectPath, options.WatchFileExtensions, logger, /* port */ 0); + case NodeHostingModel.Socket: + var pipeName = "pni-" + Guid.NewGuid().ToString("D"); // Arbitrary non-clashing string + return new SocketNodeInstance(options.ProjectPath, options.WatchFileExtensions, pipeName, logger); + default: + throw new ArgumentException("Unknown hosting model: " + options.HostingModel); + } + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/NodeHostingModel.cs b/src/Microsoft.AspNetCore.NodeServices/Configuration/NodeHostingModel.cs similarity index 100% rename from src/Microsoft.AspNetCore.NodeServices/NodeHostingModel.cs rename to src/Microsoft.AspNetCore.NodeServices/Configuration/NodeHostingModel.cs diff --git a/src/Microsoft.AspNetCore.NodeServices/Configuration/NodeServicesOptions.cs b/src/Microsoft.AspNetCore.NodeServices/Configuration/NodeServicesOptions.cs new file mode 100644 index 00000000..98c50ecb --- /dev/null +++ b/src/Microsoft.AspNetCore.NodeServices/Configuration/NodeServicesOptions.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.AspNetCore.NodeServices.HostingModels; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.NodeServices +{ + public class NodeServicesOptions + { + public const NodeHostingModel DefaultNodeHostingModel = NodeHostingModel.Http; + + private static readonly string[] DefaultWatchFileExtensions = { ".js", ".jsx", ".ts", ".tsx", ".json", ".html" }; + + public NodeServicesOptions() + { + HostingModel = DefaultNodeHostingModel; + WatchFileExtensions = (string[])DefaultWatchFileExtensions.Clone(); + } + public Action OnBeforeStartExternalProcess { get; set; } + public NodeHostingModel HostingModel { get; set; } + public Func NodeInstanceFactory { get; set; } + public string ProjectPath { get; set; } + public string[] WatchFileExtensions { get; set; } + public ILogger NodeInstanceOutputLogger { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-http.js b/src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-http.js index 795317e0..fef873e7 100644 --- a/src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-http.js +++ b/src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-http.js @@ -54,25 +54,15 @@ "use strict"; // Limit dependencies to core Node modules. This means the code in this file has to be very low-level and unattractive, // but simplifies things for the consumer of this module. - var http = __webpack_require__(2); - var path = __webpack_require__(3); - var ArgsUtil_1 = __webpack_require__(4); - var AutoQuit_1 = __webpack_require__(5); + __webpack_require__(2); + var http = __webpack_require__(3); + var path = __webpack_require__(4); + var ArgsUtil_1 = __webpack_require__(5); // Webpack doesn't support dynamic requires for files not present at compile time, so grab a direct // reference to Node's runtime 'require' function. var dynamicRequire = eval('require'); - var parsedArgs = ArgsUtil_1.parseArgs(process.argv); - if (parsedArgs.watch) { - AutoQuit_1.autoQuitOnFileChange(process.cwd(), parsedArgs.watch.split(',')); - } var server = http.createServer(function (req, res) { readRequestBodyAsJson(req, function (bodyJson) { - var resolvedPath = path.resolve(process.cwd(), bodyJson.moduleName); - var invokedModule = dynamicRequire(resolvedPath); - var func = bodyJson.exportedFunctionName ? invokedModule[bodyJson.exportedFunctionName] : invokedModule; - if (!func) { - throw new Error('The module "' + resolvedPath + '" has no export named "' + bodyJson.exportedFunctionName + '"'); - } var hasSentResult = false; var callback = function (errorValue, successValue) { if (!hasSentResult) { @@ -110,6 +100,12 @@ } }); try { + var resolvedPath = path.resolve(process.cwd(), bodyJson.moduleName); + var invokedModule = dynamicRequire(resolvedPath); + var func = bodyJson.exportedFunctionName ? invokedModule[bodyJson.exportedFunctionName] : invokedModule; + if (!func) { + throw new Error('The module "' + resolvedPath + '" has no export named "' + bodyJson.exportedFunctionName + '"'); + } func.apply(null, [callback].concat(bodyJson.args)); } catch (synchronousException) { @@ -117,6 +113,7 @@ } }); }); + var parsedArgs = ArgsUtil_1.parseArgs(process.argv); var requestedPortOrZero = parsedArgs.port || 0; // 0 means 'let the OS decide' server.listen(requestedPortOrZero, 'localhost', function () { // Signal to HttpNodeHost which port it should make its HTTP connections on @@ -136,16 +133,57 @@ /* 2 */ /***/ function(module, exports) { - module.exports = require("http"); + // When Node writes to stdout/strerr, we capture that and convert the lines into calls on the + // active .NET ILogger. But by default, stdout/stderr don't have any way of distinguishing + // linebreaks inside log messages from the linebreaks that delimit separate log messages, + // so multiline strings will end up being written to the ILogger as multiple independent + // log messages. This makes them very hard to make sense of, especially when they represent + // something like stack traces. + // + // To fix this, we intercept stdout/stderr writes, and replace internal linebreaks with a + // marker token. When .NET receives the lines, it converts the marker tokens back to regular + // linebreaks within the logged messages. + // + // Note that it's better to do the interception at the stdout/stderr level, rather than at + // the console.log/console.error (etc.) level, because this takes place after any native + // message formatting has taken place (e.g., inserting values for % placeholders). + var findInternalNewlinesRegex = /\n(?!$)/g; + var encodedNewline = '__ns_newline__'; + encodeNewlinesWrittenToStream(process.stdout); + encodeNewlinesWrittenToStream(process.stderr); + function encodeNewlinesWrittenToStream(outputStream) { + var origWriteFunction = outputStream.write; + outputStream.write = function (value) { + // Only interfere with the write if it's definitely a string + if (typeof value === 'string') { + var argsClone = Array.prototype.slice.call(arguments, 0); + argsClone[0] = encodeNewlinesInString(value); + origWriteFunction.apply(this, argsClone); + } + else { + origWriteFunction.apply(this, arguments); + } + }; + } + function encodeNewlinesInString(str) { + return str.replace(findInternalNewlinesRegex, encodedNewline); + } + /***/ }, /* 3 */ /***/ function(module, exports) { - module.exports = require("path"); + module.exports = require("http"); /***/ }, /* 4 */ +/***/ function(module, exports) { + + module.exports = require("path"); + +/***/ }, +/* 5 */ /***/ function(module, exports) { "use strict"; @@ -170,32 +208,5 @@ exports.parseArgs = parseArgs; -/***/ }, -/* 5 */ -/***/ function(module, exports, __webpack_require__) { - - "use strict"; - var fs = __webpack_require__(6); - var path = __webpack_require__(3); - function autoQuitOnFileChange(rootDir, extensions) { - // Note: This will only work on Windows/OS X, because the 'recursive' option isn't supported on Linux. - // Consider using a different watch mechanism (though ideally without forcing further NPM dependencies). - fs.watch(rootDir, { persistent: false, recursive: true }, function (event, filename) { - var ext = path.extname(filename); - if (extensions.indexOf(ext) >= 0) { - console.log('Restarting due to file change: ' + filename); - process.exit(0); - } - }); - } - exports.autoQuitOnFileChange = autoQuitOnFileChange; - - -/***/ }, -/* 6 */ -/***/ function(module, exports) { - - module.exports = require("fs"); - /***/ } /******/ ]))); \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-socket.js b/src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-socket.js index 2c1cd9d4..e59b6edf 100644 --- a/src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-socket.js +++ b/src/Microsoft.AspNetCore.NodeServices/Content/Node/entrypoint-socket.js @@ -44,19 +44,60 @@ /* 0 */ /***/ function(module, exports, __webpack_require__) { - module.exports = __webpack_require__(7); + module.exports = __webpack_require__(6); /***/ }, /* 1 */, -/* 2 */, -/* 3 */ +/* 2 */ /***/ function(module, exports) { - module.exports = require("path"); + // When Node writes to stdout/strerr, we capture that and convert the lines into calls on the + // active .NET ILogger. But by default, stdout/stderr don't have any way of distinguishing + // linebreaks inside log messages from the linebreaks that delimit separate log messages, + // so multiline strings will end up being written to the ILogger as multiple independent + // log messages. This makes them very hard to make sense of, especially when they represent + // something like stack traces. + // + // To fix this, we intercept stdout/stderr writes, and replace internal linebreaks with a + // marker token. When .NET receives the lines, it converts the marker tokens back to regular + // linebreaks within the logged messages. + // + // Note that it's better to do the interception at the stdout/stderr level, rather than at + // the console.log/console.error (etc.) level, because this takes place after any native + // message formatting has taken place (e.g., inserting values for % placeholders). + var findInternalNewlinesRegex = /\n(?!$)/g; + var encodedNewline = '__ns_newline__'; + encodeNewlinesWrittenToStream(process.stdout); + encodeNewlinesWrittenToStream(process.stderr); + function encodeNewlinesWrittenToStream(outputStream) { + var origWriteFunction = outputStream.write; + outputStream.write = function (value) { + // Only interfere with the write if it's definitely a string + if (typeof value === 'string') { + var argsClone = Array.prototype.slice.call(arguments, 0); + argsClone[0] = encodeNewlinesInString(value); + origWriteFunction.apply(this, argsClone); + } + else { + origWriteFunction.apply(this, arguments); + } + }; + } + function encodeNewlinesInString(str) { + return str.replace(findInternalNewlinesRegex, encodedNewline); + } + /***/ }, +/* 3 */, /* 4 */ +/***/ function(module, exports) { + + module.exports = require("path"); + +/***/ }, +/* 5 */ /***/ function(module, exports) { "use strict"; @@ -81,53 +122,22 @@ exports.parseArgs = parseArgs; -/***/ }, -/* 5 */ -/***/ function(module, exports, __webpack_require__) { - - "use strict"; - var fs = __webpack_require__(6); - var path = __webpack_require__(3); - function autoQuitOnFileChange(rootDir, extensions) { - // Note: This will only work on Windows/OS X, because the 'recursive' option isn't supported on Linux. - // Consider using a different watch mechanism (though ideally without forcing further NPM dependencies). - fs.watch(rootDir, { persistent: false, recursive: true }, function (event, filename) { - var ext = path.extname(filename); - if (extensions.indexOf(ext) >= 0) { - console.log('Restarting due to file change: ' + filename); - process.exit(0); - } - }); - } - exports.autoQuitOnFileChange = autoQuitOnFileChange; - - /***/ }, /* 6 */ -/***/ function(module, exports) { - - module.exports = require("fs"); - -/***/ }, -/* 7 */ /***/ function(module, exports, __webpack_require__) { "use strict"; // Limit dependencies to core Node modules. This means the code in this file has to be very low-level and unattractive, // but simplifies things for the consumer of this module. - var net = __webpack_require__(8); - var path = __webpack_require__(3); - var readline = __webpack_require__(9); - var ArgsUtil_1 = __webpack_require__(4); - var AutoQuit_1 = __webpack_require__(5); - var virtualConnectionServer = __webpack_require__(10); + __webpack_require__(2); + var net = __webpack_require__(7); + var path = __webpack_require__(4); + var readline = __webpack_require__(8); + var ArgsUtil_1 = __webpack_require__(5); + var virtualConnectionServer = __webpack_require__(9); // Webpack doesn't support dynamic requires for files not present at compile time, so grab a direct // reference to Node's runtime 'require' function. var dynamicRequire = eval('require'); - var parsedArgs = ArgsUtil_1.parseArgs(process.argv); - if (parsedArgs.watch) { - AutoQuit_1.autoQuitOnFileChange(process.cwd(), parsedArgs.watch.split(',')); - } // Signal to the .NET side when we're ready to accept invocations var server = net.createServer().on('listening', function () { console.log('[Microsoft.AspNetCore.NodeServices:Listening]'); @@ -176,29 +186,30 @@ // Begin listening now. The underlying transport varies according to the runtime platform. // On Windows it's Named Pipes; on Linux/OSX it's Domain Sockets. var useWindowsNamedPipes = /^win/.test(process.platform); - var listenAddress = (useWindowsNamedPipes ? '\\\\.\\pipe\\' : '/tmp/') + parsedArgs.pipename; + var parsedArgs = ArgsUtil_1.parseArgs(process.argv); + var listenAddress = (useWindowsNamedPipes ? '\\\\.\\pipe\\' : '/tmp/') + parsedArgs.listenAddress; server.listen(listenAddress); /***/ }, -/* 8 */ +/* 7 */ /***/ function(module, exports) { module.exports = require("net"); /***/ }, -/* 9 */ +/* 8 */ /***/ function(module, exports) { module.exports = require("readline"); /***/ }, -/* 10 */ +/* 9 */ /***/ function(module, exports, __webpack_require__) { "use strict"; - var events_1 = __webpack_require__(11); - var VirtualConnection_1 = __webpack_require__(12); + var events_1 = __webpack_require__(10); + var VirtualConnection_1 = __webpack_require__(11); // Keep this in sync with the equivalent constant in the .NET code. Both sides split up their transmissions into frames with this max length, // and both will reject longer frames. var MaxFrameBodyLength = 16 * 1024; @@ -379,13 +390,13 @@ /***/ }, -/* 11 */ +/* 10 */ /***/ function(module, exports) { module.exports = require("events"); /***/ }, -/* 12 */ +/* 11 */ /***/ function(module, exports, __webpack_require__) { "use strict"; @@ -394,7 +405,7 @@ function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; - var stream_1 = __webpack_require__(13); + var stream_1 = __webpack_require__(12); /** * Represents a virtual connection. Multiple virtual connections may be multiplexed over a single physical socket connection. */ @@ -435,7 +446,7 @@ /***/ }, -/* 13 */ +/* 12 */ /***/ function(module, exports) { module.exports = require("stream"); diff --git a/src/Microsoft.AspNetCore.NodeServices/HostingModels/HttpNodeInstance.cs b/src/Microsoft.AspNetCore.NodeServices/HostingModels/HttpNodeInstance.cs index a2aeaf19..53dabf92 100644 --- a/src/Microsoft.AspNetCore.NodeServices/HostingModels/HttpNodeInstance.cs +++ b/src/Microsoft.AspNetCore.NodeServices/HostingModels/HttpNodeInstance.cs @@ -4,11 +4,21 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; -namespace Microsoft.AspNetCore.NodeServices +namespace Microsoft.AspNetCore.NodeServices.HostingModels { + /// + /// A specialisation of the OutOfProcessNodeInstance base class that uses HTTP to perform RPC invocations. + /// + /// The Node child process starts an HTTP listener on an arbitrary available port (except where a nonzero + /// port number is specified as a constructor parameter), and signals which port was selected using the same + /// input/output-based mechanism that the base class uses to determine when the child process is ready to + /// accept RPC invocations. + /// + /// internal class HttpNodeInstance : OutOfProcessNodeInstance { private static readonly Regex PortMessageRegex = @@ -19,37 +29,30 @@ internal class HttpNodeInstance : OutOfProcessNodeInstance ContractResolver = new CamelCasePropertyNamesContractResolver() }; - private HttpClient _client; + private readonly HttpClient _client; private bool _disposed; private int _portNumber; - public HttpNodeInstance(string projectPath, int port = 0, string[] watchFileExtensions = null) + public HttpNodeInstance(string projectPath, string[] watchFileExtensions, ILogger nodeInstanceOutputLogger, int port = 0) : base( EmbeddedResourceReader.Read( typeof(HttpNodeInstance), "/Content/Node/entrypoint-http.js"), projectPath, - MakeCommandLineOptions(port, watchFileExtensions)) + watchFileExtensions, + MakeCommandLineOptions(port), + nodeInstanceOutputLogger) { _client = new HttpClient(); - } + } - private static string MakeCommandLineOptions(int port, string[] watchFileExtensions) + private static string MakeCommandLineOptions(int port) { - var result = "--port " + port; - if (watchFileExtensions != null && watchFileExtensions.Length > 0) - { - result += " --watch " + string.Join(",", watchFileExtensions); - } - - return result; + return $"--port {port}"; } - public override async Task Invoke(NodeInvocationInfo invocationInfo) + protected override async Task InvokeExportAsync(NodeInvocationInfo invocationInfo) { - await EnsureReady(); - - // TODO: Use System.Net.Http.Formatting (PostAsJsonAsync etc.) var payloadJson = JsonConvert.SerializeObject(invocationInfo, JsonSerializerSettings); var payload = new StringContent(payloadJson, Encoding.UTF8, "application/json"); var response = await _client.PostAsync("http://localhost:" + _portNumber, payload); @@ -97,6 +100,9 @@ public override async Task Invoke(NodeInvocationInfo invocationInfo) protected override void OnOutputDataReceived(string outputData) { + // Watch for "port selected" messages, and when observed, store the port number + // so we can use it when making HTTP requests. The child process will always send + // one of these messages before it sends a "ready for connections" message. var match = _portNumber != 0 ? null : PortMessageRegex.Match(outputData); if (match != null && match.Success) { @@ -108,24 +114,19 @@ protected override void OnOutputDataReceived(string outputData) } } - protected override void OnBeforeLaunchProcess() + protected override void Dispose(bool disposing) { - // Prepare to receive a new port number - _portNumber = 0; - } - - protected override void Dispose(bool disposing) { - base.Dispose(disposing); + base.Dispose(disposing); - if (!_disposed) + if (!_disposed) { - if (disposing) + if (disposing) { - _client.Dispose(); - } + _client.Dispose(); + } - _disposed = true; - } - } - } + _disposed = true; + } + } + } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/HostingModels/INodeInstance.cs b/src/Microsoft.AspNetCore.NodeServices/HostingModels/INodeInstance.cs new file mode 100644 index 00000000..cac69f2a --- /dev/null +++ b/src/Microsoft.AspNetCore.NodeServices/HostingModels/INodeInstance.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.NodeServices.HostingModels +{ + public interface INodeInstance : IDisposable + { + Task InvokeExportAsync(string moduleName, string exportNameOrNull, params object[] args); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationException.cs b/src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationException.cs index 64398c4c..733af444 100644 --- a/src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationException.cs +++ b/src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationException.cs @@ -1,12 +1,20 @@ using System; -namespace Microsoft.AspNetCore.NodeServices +namespace Microsoft.AspNetCore.NodeServices.HostingModels { public class NodeInvocationException : Exception { + public bool NodeInstanceUnavailable { get; private set; } + public NodeInvocationException(string message, string details) : base(message + Environment.NewLine + details) { } + + public NodeInvocationException(string message, string details, bool nodeInstanceUnavailable) + : this(message, details) + { + NodeInstanceUnavailable = nodeInstanceUnavailable; + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationInfo.cs b/src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationInfo.cs index 2e196f6e..92d96f8c 100644 --- a/src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationInfo.cs +++ b/src/Microsoft.AspNetCore.NodeServices/HostingModels/NodeInvocationInfo.cs @@ -1,4 +1,4 @@ -namespace Microsoft.AspNetCore.NodeServices +namespace Microsoft.AspNetCore.NodeServices.HostingModels { public class NodeInvocationInfo { diff --git a/src/Microsoft.AspNetCore.NodeServices/HostingModels/OutOfProcessNodeInstance.cs b/src/Microsoft.AspNetCore.NodeServices/HostingModels/OutOfProcessNodeInstance.cs index 296bb488..06649994 100644 --- a/src/Microsoft.AspNetCore.NodeServices/HostingModels/OutOfProcessNodeInstance.cs +++ b/src/Microsoft.AspNetCore.NodeServices/HostingModels/OutOfProcessNodeInstance.cs @@ -1,48 +1,75 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; -namespace Microsoft.AspNetCore.NodeServices +namespace Microsoft.AspNetCore.NodeServices.HostingModels { /// - /// Class responsible for launching the Node child process, determining when it is ready to accept invocations, - /// and finally killing it when the parent process exits. Also it restarts the child process if it dies. + /// Class responsible for launching a Node child process on the local machine, determining when it is ready to + /// accept invocations, detecting if it dies on its own, and finally terminating it on disposal. + /// + /// This abstract base class uses the input/output streams of the child process to perform a simple handshake + /// to determine when the child process is ready to accept invocations. This is agnostic to the mechanism that + /// derived classes use to actually perform the invocations (e.g., they could use HTTP-RPC, or a binary TCP + /// protocol, or any other RPC-type mechanism). /// - /// - public abstract class OutOfProcessNodeInstance : INodeServices + /// + public abstract class OutOfProcessNodeInstance : INodeInstance { - private readonly object _childProcessLauncherLock; - private string _commandLineArguments; - private readonly StringAsTempFile _entryPointScript; - private Process _nodeProcess; - private TaskCompletionSource _nodeProcessIsReadySource; - private readonly string _projectPath; + protected readonly ILogger OutputLogger; + private const string ConnectionEstablishedMessage = "[Microsoft.AspNetCore.NodeServices:Listening]"; + private readonly TaskCompletionSource _connectionIsReadySource = new TaskCompletionSource(); private bool _disposed; + private readonly StringAsTempFile _entryPointScript; + private FileSystemWatcher _fileSystemWatcher; + private readonly Process _nodeProcess; + private bool _nodeProcessNeedsRestart; + private readonly string[] _watchFileExtensions; - public OutOfProcessNodeInstance(string entryPointScript, string projectPath, string commandLineArguments = null) + public OutOfProcessNodeInstance( + string entryPointScript, + string projectPath, + string[] watchFileExtensions, + string commandLineArguments, + ILogger nodeOutputLogger) { - _childProcessLauncherLock = new object(); + if (nodeOutputLogger == null) + { + throw new ArgumentNullException(nameof(nodeOutputLogger)); + } + + OutputLogger = nodeOutputLogger; _entryPointScript = new StringAsTempFile(entryPointScript); - _projectPath = projectPath; - _commandLineArguments = commandLineArguments ?? string.Empty; + + var startInfo = PrepareNodeProcessStartInfo(_entryPointScript.FileName, projectPath, commandLineArguments); + _nodeProcess = LaunchNodeProcess(startInfo); + _watchFileExtensions = watchFileExtensions; + _fileSystemWatcher = BeginFileWatcher(projectPath); + ConnectToInputOutputStreams(); } - public string CommandLineArguments + public async Task InvokeExportAsync(string moduleName, string exportNameOrNull, params object[] args) { - get { return _commandLineArguments; } - set { _commandLineArguments = value; } - } + if (_nodeProcess.HasExited || _nodeProcessNeedsRestart) + { + // This special kind of exception triggers a transparent retry - NodeServicesImpl will launch + // a new Node instance and pass the invocation to that one instead. + var message = _nodeProcess.HasExited + ? "The Node process has exited" + : "The Node process needs to restart"; + throw new NodeInvocationException(message, null, nodeInstanceUnavailable: true); + } - public Task Invoke(string moduleName, params object[] args) - => InvokeExport(moduleName, null, args); + // Wait until the connection is established. This will throw if the connection fails to initialize. + await _connectionIsReadySource.Task; - public Task InvokeExport(string moduleName, string exportedFunctionName, params object[] args) - { - return Invoke(new NodeInvocationInfo + return await InvokeExportAsync(new NodeInvocationInfo { ModuleName = moduleName, - ExportedFunctionName = exportedFunctionName, + ExportedFunctionName = exportNameOrNull, Args = args }); } @@ -53,78 +80,120 @@ public void Dispose() GC.SuppressFinalize(this); } - public abstract Task Invoke(NodeInvocationInfo invocationInfo); + protected abstract Task InvokeExportAsync(NodeInvocationInfo invocationInfo); - protected void ExitNodeProcess() + // This method is virtual, as it provides a way to override the NODE_PATH or the path to node.exe + protected virtual ProcessStartInfo PrepareNodeProcessStartInfo( + string entryPointFilename, string projectPath, string commandLineArguments) { - if (_nodeProcess != null && !_nodeProcess.HasExited) + var startInfo = new ProcessStartInfo("node") { - // TODO: Is there a more graceful way to end it? Or does this still let it perform any cleanup? - _nodeProcess.Kill(); + Arguments = "\"" + entryPointFilename + "\" " + (commandLineArguments ?? string.Empty), + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = projectPath + }; + + // Append projectPath to NODE_PATH so it can locate node_modules + var existingNodePath = Environment.GetEnvironmentVariable("NODE_PATH") ?? string.Empty; + if (existingNodePath != string.Empty) + { + existingNodePath += ":"; } + + var nodePathValue = existingNodePath + Path.Combine(projectPath, "node_modules"); +#if NET451 + startInfo.EnvironmentVariables["NODE_PATH"] = nodePathValue; +#else + startInfo.Environment["NODE_PATH"] = nodePathValue; +#endif + + return startInfo; + } + + protected virtual void OnOutputDataReceived(string outputData) + { + OutputLogger.LogInformation(outputData); + } + + protected virtual void OnErrorDataReceived(string errorData) + { + OutputLogger.LogError(errorData); } - protected async Task EnsureReady() + protected virtual void Dispose(bool disposing) { - lock (_childProcessLauncherLock) + if (!_disposed) { - if (_nodeProcess == null || _nodeProcess.HasExited) + if (disposing) { - this.OnBeforeLaunchProcess(); + _entryPointScript.Dispose(); + EnsureFileSystemWatcherIsDisposed(); + } - var startInfo = new ProcessStartInfo("node") - { - Arguments = "\"" + _entryPointScript.FileName + "\" " + _commandLineArguments, - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - WorkingDirectory = _projectPath - }; - - // Append projectPath to NODE_PATH so it can locate node_modules - var existingNodePath = Environment.GetEnvironmentVariable("NODE_PATH") ?? string.Empty; - if (existingNodePath != string.Empty) - { - existingNodePath += ":"; - } + // Make sure the Node process is finished + // TODO: Is there a more graceful way to end it? Or does this still let it perform any cleanup? + if (!_nodeProcess.HasExited) + { + _nodeProcess.Kill(); + } - var nodePathValue = existingNodePath + Path.Combine(_projectPath, "node_modules"); -#if NET451 - startInfo.EnvironmentVariables["NODE_PATH"] = nodePathValue; -#else - startInfo.Environment["NODE_PATH"] = nodePathValue; -#endif + _disposed = true; + } + } - _nodeProcess = Process.Start(startInfo); - ConnectToInputOutputStreams(); - } + private void EnsureFileSystemWatcherIsDisposed() + { + if (_fileSystemWatcher != null) + { + _fileSystemWatcher.Dispose(); + _fileSystemWatcher = null; } + } + + private static Process LaunchNodeProcess(ProcessStartInfo startInfo) + { + var process = Process.Start(startInfo); + + // On Mac at least, a killed child process is left open as a zombie until the parent + // captures its exit code. We don't need the exit code for this process, and don't want + // to use process.WaitForExit() explicitly (we'd have to block the thread until it really + // has exited), but we don't want to leave zombies lying around either. It's sufficient + // to use process.EnableRaisingEvents so that .NET will grab the exit code and let the + // zombie be cleaned away without having to block our thread. + process.EnableRaisingEvents = true; - var task = _nodeProcessIsReadySource.Task; - var initializationSucceeded = await task; + return process; + } - if (!initializationSucceeded) + private static string UnencodeNewlines(string str) + { + if (str != null) { - throw new InvalidOperationException("The Node.js process failed to initialize", task.Exception); + // The token here needs to match the const in OverrideStdOutputs.ts. + // See the comment there for why we're doing this. + str = str.Replace("__ns_newline__", Environment.NewLine); } + + return str; } private void ConnectToInputOutputStreams() { - var initializationIsCompleted = false; // TODO: Make this thread-safe? (Interlocked.Exchange etc.) - _nodeProcessIsReadySource = new TaskCompletionSource(); + var initializationIsCompleted = false; _nodeProcess.OutputDataReceived += (sender, evt) => { - if (evt.Data == "[Microsoft.AspNetCore.NodeServices:Listening]" && !initializationIsCompleted) + if (evt.Data == ConnectionEstablishedMessage && !initializationIsCompleted) { - _nodeProcessIsReadySource.SetResult(true); + _connectionIsReadySource.SetResult(null); initializationIsCompleted = true; } else if (evt.Data != null) { - OnOutputDataReceived(evt.Data); + OnOutputDataReceived(UnencodeNewlines(evt.Data)); } }; @@ -132,12 +201,16 @@ private void ConnectToInputOutputStreams() { if (evt.Data != null) { - OnErrorDataReceived(evt.Data); if (!initializationIsCompleted) { - _nodeProcessIsReadySource.SetResult(false); + _connectionIsReadySource.SetException( + new InvalidOperationException("The Node.js process failed to initialize: " + evt.Data)); initializationIsCompleted = true; } + else + { + OnErrorDataReceived(UnencodeNewlines(evt.Data)); + } } }; @@ -145,33 +218,66 @@ private void ConnectToInputOutputStreams() _nodeProcess.BeginErrorReadLine(); } - protected virtual void OnBeforeLaunchProcess() + private FileSystemWatcher BeginFileWatcher(string rootDir) { + if (_watchFileExtensions == null || _watchFileExtensions.Length == 0) + { + // Nothing to watch + return null; + } + + var watcher = new FileSystemWatcher(rootDir) + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName + }; + watcher.Changed += OnFileChanged; + watcher.Created += OnFileChanged; + watcher.Deleted += OnFileChanged; + watcher.Renamed += OnFileRenamed; + watcher.EnableRaisingEvents = true; + return watcher; } - protected virtual void OnOutputDataReceived(string outputData) + private void OnFileChanged(object source, FileSystemEventArgs e) { - Console.WriteLine("[Node] " + outputData); + if (IsFilenameBeingWatched(e.FullPath)) + { + RestartDueToFileChange(e.FullPath); + } } - protected virtual void OnErrorDataReceived(string errorData) + private void OnFileRenamed(object source, RenamedEventArgs e) { - Console.WriteLine("[Node] " + errorData); + if (IsFilenameBeingWatched(e.OldFullPath) || IsFilenameBeingWatched(e.FullPath)) + { + RestartDueToFileChange(e.OldFullPath); + } } - protected virtual void Dispose(bool disposing) + private bool IsFilenameBeingWatched(string fullPath) { - if (!_disposed) + if (string.IsNullOrEmpty(fullPath)) { - if (disposing) - { - _entryPointScript.Dispose(); - } + return false; + } + else + { + var actualExtension = Path.GetExtension(fullPath) ?? string.Empty; + return _watchFileExtensions.Any(actualExtension.Equals); + } + } - ExitNodeProcess(); + private void RestartDueToFileChange(string fullPath) + { + OutputLogger.LogInformation($"Node will restart because file changed: {fullPath}"); - _disposed = true; - } + _nodeProcessNeedsRestart = true; + + // There's no need to watch for any more changes, since we're already restarting, and if the + // restart takes some time (e.g., due to connection draining), we could end up getting duplicate + // notifications. + EnsureFileSystemWatcherIsDisposed(); } ~OutOfProcessNodeInstance() diff --git a/src/Microsoft.AspNetCore.NodeServices/HostingModels/SocketNodeInstance.cs b/src/Microsoft.AspNetCore.NodeServices/HostingModels/SocketNodeInstance.cs index abeeac5e..f5bbce1a 100644 --- a/src/Microsoft.AspNetCore.NodeServices/HostingModels/SocketNodeInstance.cs +++ b/src/Microsoft.AspNetCore.NodeServices/HostingModels/SocketNodeInstance.cs @@ -5,43 +5,74 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.NodeServices.HostingModels.PhysicalConnections; using Microsoft.AspNetCore.NodeServices.HostingModels.VirtualConnections; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; -namespace Microsoft.AspNetCore.NodeServices +namespace Microsoft.AspNetCore.NodeServices.HostingModels { + /// + /// A specialisation of the OutOfProcessNodeInstance base class that uses a lightweight binary streaming protocol + /// to perform RPC invocations. The physical transport is Named Pipes on Windows, or Domain Sockets on Linux/Mac. + /// For details on the binary streaming protocol, see + /// Microsoft.AspNetCore.NodeServices.HostingModels.VirtualConnections.VirtualConnectionClient. + /// The advantage versus using HTTP for RPC is that this is faster (not surprisingly - there's much less overhead + /// because we don't need most of the functionality of HTTP. + /// + /// The address of the pipe/socket is selected randomly here on the .NET side and sent to the child process as a + /// command-line argument (the address space is wide enough that there's no real risk of a clash, unlike when + /// selecting TCP port numbers). + /// + /// internal class SocketNodeInstance : OutOfProcessNodeInstance { - private readonly static JsonSerializerSettings jsonSerializerSettings = new JsonSerializerSettings + private readonly static JsonSerializerSettings jsonSerializerSettings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }; - private string _addressForNextConnection; - private readonly SemaphoreSlim _clientModificationSemaphore = new SemaphoreSlim(1); - private StreamConnection _currentPhysicalConnection; - private VirtualConnectionClient _currentVirtualConnectionClient; - private readonly string[] _watchFileExtensions; + private readonly SemaphoreSlim _connectionCreationSemaphore = new SemaphoreSlim(1); + private bool _connectionHasFailed; + private StreamConnection _physicalConnection; + private string _socketAddress; + private VirtualConnectionClient _virtualConnectionClient; - public SocketNodeInstance(string projectPath, string[] watchFileExtensions = null): base( + public SocketNodeInstance(string projectPath, string[] watchFileExtensions, string socketAddress, ILogger nodeInstanceOutputLogger) : base( EmbeddedResourceReader.Read( typeof(SocketNodeInstance), "/Content/Node/entrypoint-socket.js"), - projectPath) + projectPath, + watchFileExtensions, + MakeNewCommandLineOptions(socketAddress), + nodeInstanceOutputLogger) { - _watchFileExtensions = watchFileExtensions; - } + _socketAddress = socketAddress; + } - public override async Task Invoke(NodeInvocationInfo invocationInfo) + protected override async Task InvokeExportAsync(NodeInvocationInfo invocationInfo) { - await EnsureReady(); - var virtualConnectionClient = await GetOrCreateVirtualConnectionClientAsync(); + if (_connectionHasFailed) + { + // This special exception type forces NodeServicesImpl to restart the Node instance + throw new NodeInvocationException( + "The SocketNodeInstance socket connection failed. See logs to identify the reason.", + null, + nodeInstanceUnavailable: true); + } + + if (_virtualConnectionClient == null) + { + await EnsureVirtualConnectionClientCreated(); + } + // For each invocation, we open a new virtual connection. This gives an API equivalent to opening a new + // physical connection to the child process, but without the overhead of doing so, because it's really + // just multiplexed into the existing physical connection stream. bool shouldDisposeVirtualConnection = true; Stream virtualConnection = null; try { - virtualConnection = _currentVirtualConnectionClient.OpenVirtualConnection(); + virtualConnection = _virtualConnectionClient.OpenVirtualConnection(); // Send request await WriteJsonLineAsync(virtualConnection, invocationInfo); @@ -75,46 +106,32 @@ public override async Task Invoke(NodeInvocationInfo invocationInfo) } } - private async Task GetOrCreateVirtualConnectionClientAsync() + private async Task EnsureVirtualConnectionClientCreated() { - var client = _currentVirtualConnectionClient; - if (client == null) + // Asynchronous equivalent to a 'lock(...) { ... }' + await _connectionCreationSemaphore.WaitAsync(); + try { - await _clientModificationSemaphore.WaitAsync(); - try + if (_virtualConnectionClient == null) { - if (_currentVirtualConnectionClient == null) + _physicalConnection = StreamConnection.Create(); + + var connection = await _physicalConnection.Open(_socketAddress); + _virtualConnectionClient = new VirtualConnectionClient(connection); + _virtualConnectionClient.OnError += (ex) => { - var address = _addressForNextConnection; - if (string.IsNullOrEmpty(address)) - { - // This shouldn't happen, because we always await 'EnsureReady' before getting here. - throw new InvalidOperationException("Cannot open connection to Node process until it has signalled that it is ready"); - } - - _currentPhysicalConnection = StreamConnection.Create(); - - var connection = await _currentPhysicalConnection.Open(address); - _currentVirtualConnectionClient = new VirtualConnectionClient(connection); - _currentVirtualConnectionClient.OnError += (ex) => - { - // TODO: Log the exception properly. Need to change the chain of calls up to this point to supply - // an ILogger or IServiceProvider etc. - Console.WriteLine(ex.Message); - ExitNodeProcess(); // We'll restart it next time there's a request to it - }; - } + // This callback is fired only if there's a protocol-level failure (e.g., child process disconnected + // unexpectedly). It does *not* fire when RPC calls return errors. Since there's been a protocol-level + // failure, this Node instance is no longer usable and should be discarded. + _connectionHasFailed = true; - return _currentVirtualConnectionClient; - } - finally - { - _clientModificationSemaphore.Release(); + OutputLogger.LogError(0, ex, ex.Message); + }; } } - else + finally { - return client; + _connectionCreationSemaphore.Release(); } } @@ -122,21 +139,22 @@ protected override void Dispose(bool disposing) { if (disposing) { - EnsurePipeRpcClientDisposed(); + if (_virtualConnectionClient != null) + { + _virtualConnectionClient.Dispose(); + _virtualConnectionClient = null; + } + + if (_physicalConnection != null) + { + _physicalConnection.Dispose(); + _physicalConnection = null; + } } base.Dispose(disposing); } - protected override void OnBeforeLaunchProcess() - { - // Either we've never yet launched the Node process, or we did but the old one died. - // Stop waiting for any outstanding requests and prepare to launch the new process. - EnsurePipeRpcClientDisposed(); - _addressForNextConnection = "pni-" + Guid.NewGuid().ToString("D"); // Arbitrary non-clashing string - CommandLineArguments = MakeNewCommandLineOptions(_addressForNextConnection, _watchFileExtensions); - } - private static async Task WriteJsonLineAsync(Stream stream, object serializableObject) { var json = JsonConvert.SerializeObject(serializableObject, jsonSerializerSettings); @@ -152,7 +170,7 @@ private static async Task ReadJsonAsync(Stream stream) private static async Task ReadAllBytesAsync(Stream input) { - byte[] buffer = new byte[16*1024]; + byte[] buffer = new byte[16 * 1024]; using (var ms = new MemoryStream()) { @@ -166,39 +184,9 @@ private static async Task ReadAllBytesAsync(Stream input) } } - private static string MakeNewCommandLineOptions(string pipeName, string[] watchFileExtensions) - { - var result = "--pipename " + pipeName; - if (watchFileExtensions != null && watchFileExtensions.Length > 0) - { - result += " --watch " + string.Join(",", watchFileExtensions); - } - - return result; - } - - private void EnsurePipeRpcClientDisposed() + private static string MakeNewCommandLineOptions(string listenAddress) { - _clientModificationSemaphore.Wait(); - - try - { - if (_currentVirtualConnectionClient != null) - { - _currentVirtualConnectionClient.Dispose(); - _currentVirtualConnectionClient = null; - } - - if (_currentPhysicalConnection != null) - { - _currentPhysicalConnection.Dispose(); - _currentPhysicalConnection = null; - } - } - finally - { - _clientModificationSemaphore.Release(); - } + return $"--listenAddress {listenAddress}"; } #pragma warning disable 649 // These properties are populated via JSON deserialization diff --git a/src/Microsoft.AspNetCore.NodeServices/INodeInstance.cs b/src/Microsoft.AspNetCore.NodeServices/INodeServices.cs similarity index 53% rename from src/Microsoft.AspNetCore.NodeServices/INodeInstance.cs rename to src/Microsoft.AspNetCore.NodeServices/INodeServices.cs index 0c17ea1a..3aa09e3b 100644 --- a/src/Microsoft.AspNetCore.NodeServices/INodeInstance.cs +++ b/src/Microsoft.AspNetCore.NodeServices/INodeServices.cs @@ -5,8 +5,14 @@ namespace Microsoft.AspNetCore.NodeServices { public interface INodeServices : IDisposable { + Task InvokeAsync(string moduleName, params object[] args); + + Task InvokeExportAsync(string moduleName, string exportedFunctionName, params object[] args); + + [Obsolete("Use InvokeAsync instead")] Task Invoke(string moduleName, params object[] args); + [Obsolete("Use InvokeExportAsync instead")] Task InvokeExport(string moduleName, string exportedFunctionName, params object[] args); } -} +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/NodeServicesImpl.cs b/src/Microsoft.AspNetCore.NodeServices/NodeServicesImpl.cs new file mode 100644 index 00000000..82232150 --- /dev/null +++ b/src/Microsoft.AspNetCore.NodeServices/NodeServicesImpl.cs @@ -0,0 +1,167 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.NodeServices.HostingModels; + +namespace Microsoft.AspNetCore.NodeServices +{ + /// + /// Default implementation of INodeServices. This is the primary API surface through which developers + /// make use of this package. It provides simple "InvokeAsync" methods that dispatch calls to the + /// correct Node instance, creating and destroying those instances as needed. + /// + /// If a Node instance dies (or none was yet created), this class takes care of creating a new one. + /// If a Node instance signals that it needs to be restarted (e.g., because a file changed), then this + /// class will create a new instance and dispatch future calls to it, while keeping the old instance + /// alive for a defined period so that any in-flight RPC calls can complete. This latter feature is + /// analogous to the "connection draining" feature implemented by HTTP load balancers. + /// + /// + internal class NodeServicesImpl : INodeServices + { + private static TimeSpan ConnectionDrainingTimespan = TimeSpan.FromSeconds(15); + private NodeServicesOptions _options; + private Func _nodeInstanceFactory; + private INodeInstance _currentNodeInstance; + private object _currentNodeInstanceAccessLock = new object(); + private Exception _instanceDelayedDisposalException; + + internal NodeServicesImpl(NodeServicesOptions options, Func nodeInstanceFactory) + { + _options = options; + _nodeInstanceFactory = nodeInstanceFactory; + } + + public Task InvokeAsync(string moduleName, params object[] args) + { + return InvokeExportAsync(moduleName, null, args); + } + + public Task InvokeExportAsync(string moduleName, string exportedFunctionName, params object[] args) + { + return InvokeExportWithPossibleRetryAsync(moduleName, exportedFunctionName, args, allowRetry: true); + } + + public async Task InvokeExportWithPossibleRetryAsync(string moduleName, string exportedFunctionName, object[] args, bool allowRetry) + { + ThrowAnyOutstandingDelayedDisposalException(); + var nodeInstance = GetOrCreateCurrentNodeInstance(); + + try + { + return await nodeInstance.InvokeExportAsync(moduleName, exportedFunctionName, args); + } + catch (NodeInvocationException ex) + { + // If the Node instance can't complete the invocation because it needs to restart (e.g., because the underlying + // Node process has exited, or a file it depends on has changed), then we make one attempt to restart transparently. + if (allowRetry && ex.NodeInstanceUnavailable) + { + // Perform the retry after clearing away the old instance + // Since we disposal is delayed even though the node instance is replaced immediately, this produces the + // "connection draining" feature whereby in-flight RPC calls are given a certain period to complete. + lock (_currentNodeInstanceAccessLock) + { + if (_currentNodeInstance == nodeInstance) + { + DisposeNodeInstance(_currentNodeInstance, delay: ConnectionDrainingTimespan); + _currentNodeInstance = null; + } + } + + // One the next call, don't allow retries, because we could get into an infinite retry loop, or a long retry + // loop that masks an underlying problem. A newly-created Node instance should be able to accept invocations, + // or something more serious must be wrong. + return await InvokeExportWithPossibleRetryAsync(moduleName, exportedFunctionName, args, allowRetry: false); + } + else + { + throw; + } + } + } + + public void Dispose() + { + lock (_currentNodeInstanceAccessLock) + { + if (_currentNodeInstance != null) + { + DisposeNodeInstance(_currentNodeInstance, delay: TimeSpan.Zero); + _currentNodeInstance = null; + } + } + } + + private void DisposeNodeInstance(INodeInstance nodeInstance, TimeSpan delay) + { + if (delay == TimeSpan.Zero) + { + nodeInstance.Dispose(); + } + else + { + Task.Run(async () => { + try + { + await Task.Delay(delay); + nodeInstance.Dispose(); + } + catch(Exception ex) + { + // Nothing's waiting for the delayed disposal task, so any exceptions in it would + // by default just get ignored. To make these discoverable, capture them here so + // they can be rethrown to the next caller to InvokeExportAsync. + _instanceDelayedDisposalException = ex; + } + }); + } + } + + private void ThrowAnyOutstandingDelayedDisposalException() + { + if (_instanceDelayedDisposalException != null) + { + var ex = _instanceDelayedDisposalException; + _instanceDelayedDisposalException = null; + throw new AggregateException( + "A previous attempt to dispose a Node instance failed. See InnerException for details.", + ex); + } + } + + private INodeInstance GetOrCreateCurrentNodeInstance() + { + var instance = _currentNodeInstance; + if (instance == null) + { + lock (_currentNodeInstanceAccessLock) + { + instance = _currentNodeInstance; + if (instance == null) + { + instance = _currentNodeInstance = CreateNewNodeInstance(); + } + } + } + + return instance; + } + + private INodeInstance CreateNewNodeInstance() + { + return _nodeInstanceFactory(); + } + + // Obsolete method - will be removed soon + public Task Invoke(string moduleName, params object[] args) + { + return InvokeAsync(moduleName, args); + } + + // Obsolete method - will be removed soon + public Task InvokeExport(string moduleName, string exportedFunctionName, params object[] args) + { + return InvokeExportAsync(moduleName, exportedFunctionName, args); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/NodeServicesOptions.cs b/src/Microsoft.AspNetCore.NodeServices/NodeServicesOptions.cs deleted file mode 100644 index dccd85de..00000000 --- a/src/Microsoft.AspNetCore.NodeServices/NodeServicesOptions.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Microsoft.AspNetCore.NodeServices -{ - public class NodeServicesOptions - { - public NodeServicesOptions() - { - HostingModel = Configuration.DefaultNodeHostingModel; - } - - public NodeHostingModel HostingModel { get; set; } - public string ProjectPath { get; set; } - public string[] WatchFileExtensions { get; set; } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.NodeServices/README.md b/src/Microsoft.AspNetCore.NodeServices/README.md index f0a231be..3fb99282 100644 --- a/src/Microsoft.AspNetCore.NodeServices/README.md +++ b/src/Microsoft.AspNetCore.NodeServices/README.md @@ -9,8 +9,8 @@ This NuGet package provides a fast and robust way to invoke Node.js code from a It is the underlying mechanism supporting the following packages: - * [`Microsoft.AspNetCore.SpaServices`](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.SpaServices) - builds on NodeServices, adding functionality commonly used in Single Page Applications, such as server-side prerendering, webpack middleware, and integration between server-side and client-side routing. - * [`Microsoft.AspNetCore.AngularServices`](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.AngularServices) and [`Microsoft.AspNetCore.ReactServices`](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.ReactServices) - these build on `SpaServices`, adding helpers specific to Angular 2 and React, such as cache priming and integrating server-side and client-side validation + * [`Microsoft.AspNetCore.SpaServices`](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.SpaServices) - builds on NodeServices, adding functionality commonly used in Single Page Applications, such as server-side prerendering, webpack middleware, and integration between server-side and client-side routing. + * [`Microsoft.AspNetCore.AngularServices`](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.AngularServices) and [`Microsoft.AspNetCore.ReactServices`](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.ReactServices) - these build on `SpaServices`, adding helpers specific to Angular 2 and React, such as cache priming and integrating server-side and client-side validation ### Requirements @@ -20,7 +20,7 @@ It is the underlying mechanism supporting the following packages: * [.NET](https://dot.net) * For .NET Core (e.g., ASP.NET Core apps), you need at least 1.0 RC2 * For .NET Framework, you need at least version 4.5.1. - + ### Installation For .NET Core apps: @@ -37,7 +37,7 @@ For .NET Framework apps: In that case, you don't need to use NodeServices directly (or install it manually). You can either: * **Recommended:** Use the `aspnetcore-spa` Yeoman generator to get a ready-to-go starting point using your choice of client-side framework. [Instructions here.](http://blog.stevensanderson.com/2016/05/02/angular2-react-knockout-apps-on-aspnet-core/) -* Or set up your ASP.NET Core and client-side Angular/React/KO/etc. app manually, and then use the [`Microsoft.AspNetCore.SpaServices`](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.SpaServices) package to add features like server-side prerendering or Webpack middleware. But really, at least try using the `aspnetcore-spa` generator first. +* Or set up your ASP.NET Core and client-side Angular/React/KO/etc. app manually, and then use the [`Microsoft.AspNetCore.SpaServices`](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.SpaServices) package to add features like server-side prerendering or Webpack middleware. But really, at least try using the `aspnetcore-spa` generator first. # Simple usage example @@ -63,30 +63,12 @@ public void ConfigureServices(IServiceCollection services) } ``` -Now you can receive an instance of `NodeServices` as a constructor parameter to any MVC controller, e.g.: - -```csharp -using Microsoft.AspNetCore.NodeServices; - -public class SomeController : Controller -{ - private INodeServices _nodeServices; - - public SomeController(INodeServices nodeServices) - { - _nodeServices = nodeServices; - } - - // ... your action methods are here ... -} -``` - -Then you can use this instance to make calls into Node.js code, e.g.: +Now you can receive an instance of `NodeServices` as an action method parameter to any MVC action, and then use it to make calls into Node.js code, e.g.: ```csharp -public async Task MyAction() +public async Task MyAction([FromServices] INodeServices nodeServices) { - var result = await _nodeServices.Invoke("./addNumbers", 1, 2); + var result = await nodeServices.InvokeAsync("./addNumbers", 1, 2); return Content("1 + 2 = " + result); } ``` @@ -102,7 +84,7 @@ module.exports = function (callback, first, second) { As you can see, the exported JavaScript function will receive the arguments you pass from .NET (as long as they are JSON-serializable), along with a Node-style callback you can use to send back a result or error when you are ready. -When the `Invoke` method receives the result back from Node, the result will be JSON-deserialized to whatever generic type you specified when calling `Invoke` (e.g., above, that type is `int`). If `Invoke` receives an error from your Node code, it will throw an exception describing that error. +When the `InvokeAsync` method receives the result back from Node, the result will be JSON-deserialized to whatever generic type you specified when calling `InvokeAsync` (e.g., above, that type is `int`). If `InvokeAsync` receives an error from your Node code, it will throw an exception describing that error. If you want to put `addNumber.js` inside a subfolder rather than the root of your app, then also amend the path in the `_nodeServices.Invoke` call to match that path. @@ -116,9 +98,9 @@ In other types of .NET app where you don't have ASP.NET Core's DI system, you ca var nodeServices = Configuration.CreateNodeServices(new NodeServicesOptions()); ``` -Besides this, the usage is the same as described for ASP.NET above, so you can now call `nodeServices.Invoke(...)` etc. +Besides this, the usage is the same as described for ASP.NET above, so you can now call `nodeServices.InvokeAsync(...)` etc. -You can dispose the `nodeServices` object whenever you are done with it (and it will shut down the associated Node.js instance), but because these instances are expensive to create, you should whenever possible retain and reuse instances. They are thread-safe - you can call `Invoke` simultaneously from multiple threads. Also, `NodeServices` instances are smart enough to detect if the associated Node instance has died and will automatically start a new Node instance if needed. +You can dispose the `nodeServices` object whenever you are done with it (and it will shut down the associated Node.js instance), but because these instances are expensive to create, you should whenever possible retain and reuse instances. They are thread-safe - you can call `InvokeAsync` simultaneously from multiple threads. Also, `NodeServices` instances are smart enough to detect if the associated Node instance has died and will automatically start a new Node instance if needed. # API Reference @@ -197,17 +179,17 @@ var nodeServices = Configuration.CreateNodeServices(new NodeServicesOptions { * `HostingModel` - an `NodeHostingModel` enum value. See: [hosting models](#hosting-models) * `ProjectPath` - if specified, controls the working directory used when launching Node instances. This affects, for example, the location that `require` statements resolve relative paths against. If not specified, your application root directory is used. * `WatchFileExtensions` - if specified, the launched Node instance will watch for changes to any files with these extension, and auto-restarts when any are changed. - + **Return type:** `NodeServices` -If you create a `NodeServices` instance this way, you can also dispose it (call `nodeServiceInstance.Dispose();`) and it will shut down the associated Node instance. But because these instances are expensive to create, you should whenever possible retain and reuse your `NodeServices` object. They are thread-safe - you can call `nodeServiceInstance.Invoke(...)` simultaneously from multiple threads. +If you create a `NodeServices` instance this way, you can also dispose it (call `nodeServiceInstance.Dispose();`) and it will shut down the associated Node instance. But because these instances are expensive to create, you should whenever possible retain and reuse your `NodeServices` object. They are thread-safe - you can call `nodeServiceInstance.InvokeAsync(...)` simultaneously from multiple threads. -### Invoke<T> +### InvokeAsync<T> **Signature:** ```csharp -Invoke(string moduleName, params object[] args) +InvokeAsync(string moduleName, params object[] args) ``` Asynchronously calls a JavaScript function and returns the result, or throws an exception if the result was an error. @@ -215,7 +197,7 @@ Asynchronously calls a JavaScript function and returns the result, or throws an **Example 1: Getting a JSON-serializable object from Node (the most common use case)** ```csharp -var result = await myNodeServicesInstance.Invoke( +var result = await myNodeServicesInstance.InvokeAsync( "./Node/transpile", pathOfSomeFileToBeTranspiled); ``` @@ -226,7 +208,7 @@ var result = await myNodeServicesInstance.Invoke( public class TranspilerResult { public string Code { get; set; } - public string[] Warnings { get; set; + public string[] Warnings { get; set; } } ``` @@ -245,7 +227,7 @@ module.exports = function (callback, filePath) { **Example 2: Getting a stream of binary data from Node** ```csharp -var imageStream = await myNodeServicesInstance.Invoke( +var imageStream = await myNodeServicesInstance.InvokeAsync( "./Node/resizeImage", fullImagePath, width, @@ -268,7 +250,7 @@ module.exports = function(result, physicalPath, maxWidth, maxHeight) { } ``` -There's a working image resizing example following this approach [here](https://github.com/aspnet/JavaScriptServices/tree/master/samples/misc/NodeServicesExamples) - see the [C# code](https://github.com/aspnet/JavaScriptServices/blob/master/samples/misc/NodeServicesExamples/Controllers/ResizeImage.cs) and the [JavaScript code](https://github.com/aspnet/JavaScriptServices/blob/master/samples/misc/NodeServicesExamples/Node/resizeImage.js). +There's a working image resizing example following this approach [here](https://github.com/aspnet/JavaScriptServices/tree/dev/samples/misc/NodeServicesExamples) - see the [C# code](https://github.com/aspnet/JavaScriptServices/blob/dev/samples/misc/NodeServicesExamples/Controllers/ResizeImage.cs) and the [JavaScript code](https://github.com/aspnet/JavaScriptServices/blob/dev/samples/misc/NodeServicesExamples/Node/resizeImage.js). **Parameters** @@ -282,24 +264,24 @@ There's a working image resizing example following this approach [here](https:// * A JSON-serializable .NET type, if your JavaScript code uses the `callback(error, result)` pattern to return an object, as in example 1 above * Or, the type `System.IO.Stream`, if your JavaScript code writes data to the `result.stream` object (which is a [Node `Duplex` stream](https://nodejs.org/api/stream.html#stream_class_stream_duplex)), as in example 2 above -### InvokeExport<T> +### InvokeExportAsync<T> **Signature** ```csharp -InvokeExport(string moduleName, string exportName, params object[] args) +InvokeExportAsync(string moduleName, string exportName, params object[] args) ``` -This is exactly the same as `Invoke`, except that it also takes an `exportName` parameter. You can use this if you want your JavaScript module to export more than one function. +This is exactly the same as `InvokeAsync`, except that it also takes an `exportName` parameter. You can use this if you want your JavaScript module to export more than one function. **Example** ```csharp -var someString = await myNodeServicesInstance.Invoke( +var someString = await myNodeServicesInstance.InvokeExportAsync( "./Node/myNodeApis", "getMeAString"); -var someStringInFrench = await myNodeServicesInstance.Invoke( +var someStringInFrench = await myNodeServicesInstance.InvokeExportAsync( "./Node/myNodeApis", "convertLanguage" someString, @@ -325,7 +307,7 @@ module.exports = { }; ``` -**Parameters, return type, etc.** For all other details, see the docs for [`Invoke`](#invoket) +**Parameters, return type, etc.** For all other details, see the docs for [`InvokeAsync`](#invokeasynct) ## Hosting models @@ -367,11 +349,12 @@ The default transport may change from `Http` to `Socket` in the near future, bec ### Custom hosting models -If you implement a custom hosting model (by implementing `INodeServices`), then you can get instances of that just by using your type's constructor. Or if you want to designate it as the default hosting model that higher-level services (such as those in the `SpaServices` package) should use, register it with ASP.NET Core's DI system: +If you implement a custom hosting model (by implementing `INodeInstance`), then you can cause it to be used by populating `NodeInstanceFactory` on a `NodeServicesOptions`: ```csharp -services.AddSingleton(typeof(INodeServices), serviceProvider => -{ - return new YourCustomHostingModel(); -}); +var options = new NodeServicesOptions { + NodeInstanceFactory = () => new MyCustomNodeInstance() +}; ``` + +Now you can pass this `options` object to [`AddNodeServices`](#addnodeservices) or [`CreateNodeServices`](#createnodeservices). diff --git a/src/Microsoft.AspNetCore.NodeServices/TypeScript/HttpNodeInstanceEntryPoint.ts b/src/Microsoft.AspNetCore.NodeServices/TypeScript/HttpNodeInstanceEntryPoint.ts index 7ce84e3d..0e10d993 100644 --- a/src/Microsoft.AspNetCore.NodeServices/TypeScript/HttpNodeInstanceEntryPoint.ts +++ b/src/Microsoft.AspNetCore.NodeServices/TypeScript/HttpNodeInstanceEntryPoint.ts @@ -1,28 +1,16 @@ // Limit dependencies to core Node modules. This means the code in this file has to be very low-level and unattractive, // but simplifies things for the consumer of this module. +import './Util/OverrideStdOutputs'; import * as http from 'http'; import * as path from 'path'; import { parseArgs } from './Util/ArgsUtil'; -import { autoQuitOnFileChange } from './Util/AutoQuit'; // Webpack doesn't support dynamic requires for files not present at compile time, so grab a direct // reference to Node's runtime 'require' function. const dynamicRequire: (name: string) => any = eval('require'); -const parsedArgs = parseArgs(process.argv); -if (parsedArgs.watch) { - autoQuitOnFileChange(process.cwd(), parsedArgs.watch.split(',')); -} - const server = http.createServer((req, res) => { readRequestBodyAsJson(req, bodyJson => { - const resolvedPath = path.resolve(process.cwd(), bodyJson.moduleName); - const invokedModule = dynamicRequire(resolvedPath); - const func = bodyJson.exportedFunctionName ? invokedModule[bodyJson.exportedFunctionName] : invokedModule; - if (!func) { - throw new Error('The module "' + resolvedPath + '" has no export named "' + bodyJson.exportedFunctionName + '"'); - } - let hasSentResult = false; const callback = (errorValue, successValue) => { if (!hasSentResult) { @@ -31,9 +19,9 @@ const server = http.createServer((req, res) => { res.statusCode = 500; if (errorValue.stack) { - res.end(errorValue.stack); + res.end(errorValue.stack); } else { - res.end(errorValue.toString()); + res.end(errorValue.toString()); } } else if (typeof successValue !== 'string') { // Arbitrary object/number/etc - JSON-serialize it @@ -61,6 +49,13 @@ const server = http.createServer((req, res) => { }); try { + const resolvedPath = path.resolve(process.cwd(), bodyJson.moduleName); + const invokedModule = dynamicRequire(resolvedPath); + const func = bodyJson.exportedFunctionName ? invokedModule[bodyJson.exportedFunctionName] : invokedModule; + if (!func) { + throw new Error('The module "' + resolvedPath + '" has no export named "' + bodyJson.exportedFunctionName + '"'); + } + func.apply(null, [callback].concat(bodyJson.args)); } catch (synchronousException) { callback(synchronousException, null); @@ -68,6 +63,7 @@ const server = http.createServer((req, res) => { }); }); +const parsedArgs = parseArgs(process.argv); const requestedPortOrZero = parsedArgs.port || 0; // 0 means 'let the OS decide' server.listen(requestedPortOrZero, 'localhost', function () { // Signal to HttpNodeHost which port it should make its HTTP connections on diff --git a/src/Microsoft.AspNetCore.NodeServices/TypeScript/SocketNodeInstanceEntryPoint.ts b/src/Microsoft.AspNetCore.NodeServices/TypeScript/SocketNodeInstanceEntryPoint.ts index ce168873..0a6f713d 100644 --- a/src/Microsoft.AspNetCore.NodeServices/TypeScript/SocketNodeInstanceEntryPoint.ts +++ b/src/Microsoft.AspNetCore.NodeServices/TypeScript/SocketNodeInstanceEntryPoint.ts @@ -1,20 +1,16 @@ // Limit dependencies to core Node modules. This means the code in this file has to be very low-level and unattractive, // but simplifies things for the consumer of this module. +import './Util/OverrideStdOutputs'; import * as net from 'net'; import * as path from 'path'; import * as readline from 'readline'; import { Duplex } from 'stream'; import { parseArgs } from './Util/ArgsUtil'; -import { autoQuitOnFileChange } from './Util/AutoQuit'; import * as virtualConnectionServer from './VirtualConnections/VirtualConnectionServer'; // Webpack doesn't support dynamic requires for files not present at compile time, so grab a direct // reference to Node's runtime 'require' function. const dynamicRequire: (name: string) => any = eval('require'); -const parsedArgs = parseArgs(process.argv); -if (parsedArgs.watch) { - autoQuitOnFileChange(process.cwd(), parsedArgs.watch.split(',')); -} // Signal to the .NET side when we're ready to accept invocations const server = net.createServer().on('listening', () => { @@ -69,7 +65,8 @@ virtualConnectionServer.createInterface(server).on('connection', (connection: Du // Begin listening now. The underlying transport varies according to the runtime platform. // On Windows it's Named Pipes; on Linux/OSX it's Domain Sockets. const useWindowsNamedPipes = /^win/.test(process.platform); -const listenAddress = (useWindowsNamedPipes ? '\\\\.\\pipe\\' : '/tmp/') + parsedArgs.pipename; +const parsedArgs = parseArgs(process.argv); +const listenAddress = (useWindowsNamedPipes ? '\\\\.\\pipe\\' : '/tmp/') + parsedArgs.listenAddress; server.listen(listenAddress); interface RpcInvocation { diff --git a/src/Microsoft.AspNetCore.NodeServices/TypeScript/Util/AutoQuit.ts b/src/Microsoft.AspNetCore.NodeServices/TypeScript/Util/AutoQuit.ts deleted file mode 100644 index e65567c5..00000000 --- a/src/Microsoft.AspNetCore.NodeServices/TypeScript/Util/AutoQuit.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -export function autoQuitOnFileChange(rootDir: string, extensions: string[]) { - // Note: This will only work on Windows/OS X, because the 'recursive' option isn't supported on Linux. - // Consider using a different watch mechanism (though ideally without forcing further NPM dependencies). - fs.watch(rootDir, { persistent: false, recursive: true } as any, (event, filename) => { - var ext = path.extname(filename); - if (extensions.indexOf(ext) >= 0) { - console.log('Restarting due to file change: ' + filename); - process.exit(0); - } - }); -} diff --git a/src/Microsoft.AspNetCore.NodeServices/TypeScript/Util/OverrideStdOutputs.ts b/src/Microsoft.AspNetCore.NodeServices/TypeScript/Util/OverrideStdOutputs.ts new file mode 100644 index 00000000..01e4bc6d --- /dev/null +++ b/src/Microsoft.AspNetCore.NodeServices/TypeScript/Util/OverrideStdOutputs.ts @@ -0,0 +1,37 @@ +// When Node writes to stdout/strerr, we capture that and convert the lines into calls on the +// active .NET ILogger. But by default, stdout/stderr don't have any way of distinguishing +// linebreaks inside log messages from the linebreaks that delimit separate log messages, +// so multiline strings will end up being written to the ILogger as multiple independent +// log messages. This makes them very hard to make sense of, especially when they represent +// something like stack traces. +// +// To fix this, we intercept stdout/stderr writes, and replace internal linebreaks with a +// marker token. When .NET receives the lines, it converts the marker tokens back to regular +// linebreaks within the logged messages. +// +// Note that it's better to do the interception at the stdout/stderr level, rather than at +// the console.log/console.error (etc.) level, because this takes place after any native +// message formatting has taken place (e.g., inserting values for % placeholders). +const findInternalNewlinesRegex = /\n(?!$)/g; +const encodedNewline = '__ns_newline__'; + +encodeNewlinesWrittenToStream(process.stdout); +encodeNewlinesWrittenToStream(process.stderr); + +function encodeNewlinesWrittenToStream(outputStream: NodeJS.WritableStream) { + const origWriteFunction = outputStream.write; + outputStream.write = function (value: any) { + // Only interfere with the write if it's definitely a string + if (typeof value === 'string') { + const argsClone = Array.prototype.slice.call(arguments, 0); + argsClone[0] = encodeNewlinesInString(value); + origWriteFunction.apply(this, argsClone); + } else { + origWriteFunction.apply(this, arguments); + } + }; +} + +function encodeNewlinesInString(str: string): string { + return str.replace(findInternalNewlinesRegex, encodedNewline); +} diff --git a/src/Microsoft.AspNetCore.NodeServices/Util/StringAsTempFile.cs b/src/Microsoft.AspNetCore.NodeServices/Util/StringAsTempFile.cs index a9f04a94..4458b779 100644 --- a/src/Microsoft.AspNetCore.NodeServices/Util/StringAsTempFile.cs +++ b/src/Microsoft.AspNetCore.NodeServices/Util/StringAsTempFile.cs @@ -28,7 +28,7 @@ private void DisposeImpl(bool disposing) { if (disposing) { - // TODO: dispose managed state (managed objects). + // Would dispose managed state here, if there was any } File.Delete(FileName); diff --git a/src/Microsoft.AspNetCore.NodeServices/project.json b/src/Microsoft.AspNetCore.NodeServices/project.json index 0da37feb..415aff7c 100644 --- a/src/Microsoft.AspNetCore.NodeServices/project.json +++ b/src/Microsoft.AspNetCore.NodeServices/project.json @@ -9,6 +9,7 @@ "Microsoft.AspNetCore.Hosting.Abstractions": "1.0.0", "Microsoft.Extensions.Configuration.Json": "1.0.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "1.0.0", + "Microsoft.Extensions.Logging.Console": "1.0.0", "Microsoft.Extensions.PlatformAbstractions": "1.0.0", "Newtonsoft.Json": "9.0.1" }, diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderTagHelper.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderTagHelper.cs index b09eaae7..e0dcd293 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderTagHelper.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/PrerenderTagHelper.cs @@ -37,7 +37,6 @@ public PrerenderTagHelper(IServiceProvider serviceProvider) { _nodeServices = _fallbackNodeServices = Configuration.CreateNodeServices(new NodeServicesOptions { - HostingModel = Configuration.DefaultNodeHostingModel, ProjectPath = _applicationBasePath }); } diff --git a/src/Microsoft.AspNetCore.SpaServices/Prerendering/Prerenderer.cs b/src/Microsoft.AspNetCore.SpaServices/Prerendering/Prerenderer.cs index 5e103226..40286d14 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Prerendering/Prerenderer.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Prerendering/Prerenderer.cs @@ -25,7 +25,7 @@ public static Task RenderToString( string requestPathAndQuery, object customDataParameter) { - return nodeServices.InvokeExport( + return nodeServices.InvokeExportAsync( NodeScript.Value.FileName, "renderToString", applicationBasePath, diff --git a/src/Microsoft.AspNetCore.SpaServices/README.md b/src/Microsoft.AspNetCore.SpaServices/README.md index 4b019c18..59e05fdb 100644 --- a/src/Microsoft.AspNetCore.SpaServices/README.md +++ b/src/Microsoft.AspNetCore.SpaServices/README.md @@ -9,7 +9,7 @@ This package enables: * [**Hot module replacement**](#webpack-hot-module-replacement) so that, during development, your code and markup changes will be pushed to your browser and updated in the running application automatically, without even needing to reload the page * [**Routing helpers**](#routing-helper-mapspafallbackroute) for integrating server-side routing with client-side routing -Behind the scenes, it uses the [`Microsoft.AspNetCore.NodeServices`](https://github.com/aspnet/JavaScriptServices/tree/master/src/Microsoft.AspNetCore.NodeServices) package as a fast and robust way to invoke Node.js-hosted code from ASP.NET Core at runtime. +Behind the scenes, it uses the [`Microsoft.AspNetCore.NodeServices`](https://github.com/aspnet/JavaScriptServices/tree/dev/src/Microsoft.AspNetCore.NodeServices) package as a fast and robust way to invoke Node.js-hosted code from ASP.NET Core at runtime. ### Requirements @@ -206,7 +206,7 @@ npm install --save angular2-universal Now you can use the [`angular2-universal` APIs](https://github.com/angular/universal) from your `boot-server.ts` TypeScript module to execute your Angular 2 component on the server. The code needed for this is fairly complex, but that's unavoidable because Angular 2 supports so many different ways of being configured, and you need to provide wiring for whatever combination of DI modules you're using. -You can find an example `boot-server.ts` that renders arbitrary Angular 2 components [here](https://github.com/aspnet/JavaScriptServices/blob/master/templates/Angular2Spa/ClientApp/boot-server.ts). If you use this with your own application, you might need to edit the `serverBindings` array to reference any other DI services that your Angular 2 component depends on. +You can find an example `boot-server.ts` that renders arbitrary Angular 2 components [here](https://github.com/aspnet/JavaScriptServices/blob/dev/templates/Angular2Spa/ClientApp/boot-server.ts). If you use this with your own application, you might need to edit the `serverBindings` array to reference any other DI services that your Angular 2 component depends on. The easiest way to get started with Angular 2 server-side rendering on ASP.NET Core is to use the [aspnetcore-spa generator](http://blog.stevensanderson.com/2016/05/02/angular2-react-knockout-apps-on-aspnet-core/), which creates a ready-made working starting point. @@ -305,7 +305,7 @@ Now you should find that your React app is rendered in the page even before any The above example is extremely simple - it doesn't use `react-router`, and it doesn't load any data asynchronously. Real applications are likely to do both of these. -For an example server-side boot module that knows how to evaluate `react-router` routes and render the correct React component, see [this example](https://github.com/aspnet/JavaScriptServices/blob/master/templates/ReactReduxSpa/ClientApp/boot-server.tsx). +For an example server-side boot module that knows how to evaluate `react-router` routes and render the correct React component, see [this example](https://github.com/aspnet/JavaScriptServices/blob/dev/templates/ReactReduxSpa/ClientApp/boot-server.tsx). Supporting asynchronous data loading involves more considerations. Unlike Angular 2 applications that run asynchronously on the server and freely overwrite server-generated markup with client-generated markup, React strictly wants to run synchronously on the server and always produce the same markup on the server as it does on the client. diff --git a/src/Microsoft.AspNetCore.SpaServices/Webpack/WebpackDevMiddleware.cs b/src/Microsoft.AspNetCore.SpaServices/Webpack/WebpackDevMiddleware.cs index 8c3dbd2a..959ee46e 100644 --- a/src/Microsoft.AspNetCore.SpaServices/Webpack/WebpackDevMiddleware.cs +++ b/src/Microsoft.AspNetCore.SpaServices/Webpack/WebpackDevMiddleware.cs @@ -44,7 +44,6 @@ public static void UseWebpackDevMiddleware( var hostEnv = (IHostingEnvironment)appBuilder.ApplicationServices.GetService(typeof(IHostingEnvironment)); var nodeServices = Configuration.CreateNodeServices(new NodeServicesOptions { - HostingModel = Configuration.DefaultNodeHostingModel, ProjectPath = hostEnv.ContentRootPath, WatchFileExtensions = new string[] { } // Don't watch anything }); @@ -61,7 +60,7 @@ public static void UseWebpackDevMiddleware( suppliedOptions = options }; var devServerInfo = - nodeServices.InvokeExport(nodeScript.FileName, "createWebpackDevServer", + nodeServices.InvokeExportAsync(nodeScript.FileName, "createWebpackDevServer", JsonConvert.SerializeObject(devServerOptions)).Result; // Proxy the corresponding requests through ASP.NET and into the Node listener diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/package.json b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/package.json index 2c0651b7..11bd4ecb 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/package.json +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-prerendering/package.json @@ -1,6 +1,6 @@ { "name": "aspnet-prerendering", - "version": "1.0.2", + "version": "1.0.4", "description": "Helpers for server-side rendering of JavaScript applications in ASP.NET Core projects. Works in conjunction with the Microsoft.AspNetCore.SpaServices NuGet package.", "main": "index.js", "scripts": { @@ -10,7 +10,7 @@ "author": "Microsoft", "license": "Apache-2.0", "dependencies": { - "domain-task": "^1.0.1", + "domain-task": "^2.0.1", "es6-promise": "^3.1.2" }, "devDependencies": { diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/package.json b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/package.json index db18530e..6ff72aa8 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/package.json +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/package.json @@ -1,6 +1,6 @@ { "name": "aspnet-webpack", - "version": "1.0.5", + "version": "1.0.6", "description": "Helpers for using Webpack in ASP.NET Core projects. Works in conjunction with the Microsoft.AspNetCore.SpaServices NuGet package.", "main": "index.js", "scripts": { diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/src/LoadViaWebpack.ts b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/src/LoadViaWebpack.ts index 38cb948c..a6f6fa72 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/src/LoadViaWebpack.ts +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/src/LoadViaWebpack.ts @@ -4,6 +4,7 @@ // that your loader plugins (e.g., require('./mystyles.less')) work in exactly the same way on the server as // on the client. import 'es6-promise'; +import * as path from 'path'; import * as webpack from 'webpack'; import { requireNewCopy } from './RequireNewCopy'; @@ -40,7 +41,15 @@ function loadViaWebpackNoCache(webpackConfigPath: string, modulePath: string) const webpackConfig: webpack.Configuration = requireNewCopy(webpackConfigPath); webpackConfig.entry = modulePath; webpackConfig.target = 'node'; - webpackConfig.output = { path: '/', filename: 'webpack-output.js', libraryTarget: 'commonjs' }; + + // Make sure we preserve the 'path' and 'publicPath' config values if specified, as these + // can affect the build output (e.g., when using 'file' loader, the publicPath value gets + // set as a prefix on output paths). + webpackConfig.output = webpackConfig.output || {}; + webpackConfig.output.path = webpackConfig.output.path || '/'; + webpackConfig.output.filename = 'webpack-output.js'; + webpackConfig.output.libraryTarget = 'commonjs'; + const outputVirtualPath = path.join(webpackConfig.output.path, webpackConfig.output.filename); // In Node, we want anything under /node_modules/ to be loaded natively and not bundled into the output // (partly because it's faster, but also because otherwise there'd be different instances of modules @@ -85,7 +94,7 @@ function loadViaWebpackNoCache(webpackConfigPath: string, modulePath: string) + stats.toString({ chunks: false })); } - const fileContent = compiler.outputFileSystem.readFileSync('/webpack-output.js', 'utf8'); + const fileContent = compiler.outputFileSystem.readFileSync(outputVirtualPath, 'utf8'); const moduleInstance = requireFromString(fileContent); resolve(moduleInstance); } catch(ex) { diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/src/WebpackDevMiddleware.ts b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/src/WebpackDevMiddleware.ts index cef40e05..e33433fc 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/src/WebpackDevMiddleware.ts +++ b/src/Microsoft.AspNetCore.SpaServices/npm/aspnet-webpack/src/WebpackDevMiddleware.ts @@ -1,6 +1,6 @@ import * as connect from 'connect'; import * as webpack from 'webpack'; -import * as url from 'url' +import * as url from 'url'; import { requireNewCopy } from './RequireNewCopy'; export interface CreateDevServerCallback { @@ -98,6 +98,6 @@ function removeTrailingSlash(str: string) { return str; } -function getPath(publicPath: string){ - return url.parse(publicPath).path; -} \ No newline at end of file +function getPath(publicPath: string) { + return url.parse(publicPath).path; +} diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/package.json b/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/package.json index 02174e89..0bceb713 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/package.json +++ b/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/package.json @@ -1,8 +1,8 @@ { "name": "domain-task", - "version": "1.0.1", + "version": "2.0.1", "description": "Tracks outstanding operations for a logical thread of execution", - "main": "main.js", + "main": "index.js", "scripts": { "prepublish": "tsd update && tsc && echo 'Finished building NPM package \"domain-task\"'", "test": "echo \"Error: no test specified\" && exit 1" diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/src/fetch.ts b/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/src/fetch.ts index 9763a007..be081f04 100644 --- a/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/src/fetch.ts +++ b/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/src/fetch.ts @@ -1,7 +1,6 @@ import * as url from 'url'; import * as domain from 'domain'; import * as domainContext from 'domain-context'; -import { addTask } from './main'; const isomorphicFetch = require('isomorphic-fetch'); const isBrowser: boolean = (new Function('try { return this === window; } catch (e) { return false; }'))(); @@ -42,9 +41,33 @@ function issueRequest(baseUrl: string, req: string | Request, init?: RequestInit } export function fetch(url: string | Request, init?: RequestInit): Promise { - const promise = issueRequest(baseUrl(), url, init); - addTask(promise); - return promise; + // As of domain-task 2.0.0, we no longer auto-add the 'fetch' promise to the current domain task list. + // This is because it's misleading to do so, and can result in race-condition bugs, e.g., + // https://github.com/aspnet/JavaScriptServices/issues/166 + // + // Consider this usage: + // + // import { fetch } from 'domain-task/fetch'; + // fetch(something).then(callback1).then(callback2) ...etc... .then(data => updateCriticalAppState); + // + // If we auto-add the very first 'fetch' promise to the domain task list, then the domain task completion + // callback might fire at any point among all the chained callbacks. If there are enough chained callbacks, + // it's likely to occur before the final 'updateCriticalAppState' one. Previously we thought it was enough + // for domain-task to use setTimeout(..., 0) so that its action occurred after all synchronously-scheduled + // chained promise callbacks, but this turns out not to be the case. Current versions of Node will run + // setTimeout-scheduled callbacks *before* setImmediate ones, if their timeout has elapsed. So even if you + // use setTimeout(..., 10), then this callback will run before setImmediate(...) if there were 10ms or more + // of CPU-blocking activity. In other words, a race condition. + // + // The correct design is for the final chained promise to be the thing added to the domain task list, but + // this can only be done by the developer and not baked into the 'fetch' API. The developer needs to write + // something like: + // + // var myTask = fetch(something).then(callback1).then(callback2) ...etc... .then(data => updateCriticalAppState); + // addDomainTask(myTask); + // + // ... so that the domain-tasks-completed callback never fires until after 'updateCriticalAppState'. + return issueRequest(baseUrl(), url, init); } export function baseUrl(url?: string): string { diff --git a/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/src/index.ts b/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/src/index.ts new file mode 100644 index 00000000..78f0c17c --- /dev/null +++ b/src/Microsoft.AspNetCore.SpaServices/npm/domain-task/src/index.ts @@ -0,0 +1,3 @@ +// This file determines the top-level package exports +export { addTask, run } from './main'; +export { fetch } from './fetch'; diff --git a/templates/Angular2Spa/ClientApp/routes.ts b/templates/Angular2Spa/ClientApp/routes.ts index d693c44a..c0a45b73 100644 --- a/templates/Angular2Spa/ClientApp/routes.ts +++ b/templates/Angular2Spa/ClientApp/routes.ts @@ -4,7 +4,7 @@ import { FetchData } from './components/fetch-data/fetch-data'; import { Counter } from './components/counter/counter'; export const routes: RouterConfig = [ - { path: '', redirectTo: 'home' }, + { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'home', component: Home }, { path: 'counter', component: Counter }, { path: 'fetch-data', component: FetchData }, diff --git a/templates/Angular2Spa/package.json b/templates/Angular2Spa/package.json index 047a6ead..a9884c15 100644 --- a/templates/Angular2Spa/package.json +++ b/templates/Angular2Spa/package.json @@ -18,15 +18,15 @@ "webpack-hot-middleware": "^2.10.0" }, "dependencies": { - "@angular/common": "2.0.0-rc.3", - "@angular/compiler": "2.0.0-rc.3", - "@angular/core": "2.0.0-rc.3", - "@angular/http": "2.0.0-rc.3", - "@angular/platform-browser": "2.0.0-rc.3", - "@angular/platform-browser-dynamic": "2.0.0-rc.3", - "@angular/platform-server": "2.0.0-rc.3", - "@angular/router": "3.0.0-alpha.8", - "angular2-universal": "^0.104.1", + "@angular/common": "2.0.0-rc.4", + "@angular/compiler": "2.0.0-rc.4", + "@angular/core": "2.0.0-rc.4", + "@angular/http": "2.0.0-rc.4", + "@angular/platform-browser": "2.0.0-rc.4", + "@angular/platform-browser-dynamic": "2.0.0-rc.4", + "@angular/platform-server": "2.0.0-rc.4", + "@angular/router": "3.0.0-beta.2", + "angular2-universal": "^0.104.5", "aspnet-prerendering": "^1.0.2", "aspnet-webpack": "^1.0.1", "css": "^2.2.1", diff --git a/templates/Angular2Spa/project.json b/templates/Angular2Spa/project.json index 1e09cd25..1ce5f413 100755 --- a/templates/Angular2Spa/project.json +++ b/templates/Angular2Spa/project.json @@ -56,7 +56,8 @@ "portable-net45+win8+dnxcore50", "portable-net45+win8" ] - } + }, + "Microsoft.DotNet.Watcher.Tools": "1.0.0-preview2-final" }, "frameworks": { diff --git a/templates/Angular2Spa/webpack.config.js b/templates/Angular2Spa/webpack.config.js index bcd7d80a..877b8530 100644 --- a/templates/Angular2Spa/webpack.config.js +++ b/templates/Angular2Spa/webpack.config.js @@ -13,7 +13,7 @@ module.exports = merge({ }, module: { loaders: [ - { test: /\.ts$/, include: /ClientApp/, loader: 'ts-loader' }, + { test: /\.ts$/, include: /ClientApp/, loader: 'ts-loader?silent=true' }, { test: /\.html$/, loader: 'raw-loader' }, { test: /\.css/, loader: extractCSS.extract(['css']) } ] diff --git a/templates/KnockoutSpa/ClientApp/components/app-root/app-root.ts b/templates/KnockoutSpa/ClientApp/components/app-root/app-root.ts index 2d05cb11..7583faff 100644 --- a/templates/KnockoutSpa/ClientApp/components/app-root/app-root.ts +++ b/templates/KnockoutSpa/ClientApp/components/app-root/app-root.ts @@ -12,12 +12,12 @@ const routes: Route[] = [ class AppRootViewModel { public route: KnockoutObservable; private _router: Router; - + constructor(params: { history: HistoryModule.History }) { // Activate the client-side router this._router = new Router(params.history, routes) this.route = this._router.currentRoute; - + // Load and register all the KO components needed to handle the routes // The optional 'bundle?lazy!' prefix is a Webpack feature that causes the referenced modules // to be split into separate files that are then loaded on demand. @@ -27,12 +27,12 @@ class AppRootViewModel { ko.components.register('counter-example', require('bundle?lazy!../counter-example/counter-example')); ko.components.register('fetch-data', require('bundle?lazy!../fetch-data/fetch-data')); } - + // To support hot module replacement, this method unregisters the router and KO components. // In production scenarios where hot module replacement is disabled, this would not be invoked. public dispose() { this._router.dispose(); - + // TODO: Need a better API for this Object.getOwnPropertyNames((ko).components._allRegisteredComponents).forEach(componentName => { ko.components.unregister(componentName); diff --git a/templates/KnockoutSpa/ClientApp/components/counter-example/counter-example.ts b/templates/KnockoutSpa/ClientApp/components/counter-example/counter-example.ts index b056ed27..39b7f046 100644 --- a/templates/KnockoutSpa/ClientApp/components/counter-example/counter-example.ts +++ b/templates/KnockoutSpa/ClientApp/components/counter-example/counter-example.ts @@ -2,7 +2,7 @@ import * as ko from 'knockout'; class CounterExampleViewModel { public currentCount = ko.observable(0); - + public incrementCounter() { let prevCount = this.currentCount(); this.currentCount(prevCount + 1); diff --git a/templates/KnockoutSpa/ClientApp/components/fetch-data/fetch-data.ts b/templates/KnockoutSpa/ClientApp/components/fetch-data/fetch-data.ts index cfa71e4d..deab0682 100644 --- a/templates/KnockoutSpa/ClientApp/components/fetch-data/fetch-data.ts +++ b/templates/KnockoutSpa/ClientApp/components/fetch-data/fetch-data.ts @@ -9,7 +9,7 @@ interface WeatherForecast { class FetchDataViewModel { public forecasts = ko.observableArray(); - + constructor() { fetch('/api/SampleData/WeatherForecasts') .then(response => response.json()) diff --git a/templates/KnockoutSpa/ClientApp/components/nav-menu/nav-menu.ts b/templates/KnockoutSpa/ClientApp/components/nav-menu/nav-menu.ts index ffd476ae..186c60e1 100644 --- a/templates/KnockoutSpa/ClientApp/components/nav-menu/nav-menu.ts +++ b/templates/KnockoutSpa/ClientApp/components/nav-menu/nav-menu.ts @@ -7,7 +7,7 @@ interface NavMenuParams { class NavMenuViewModel { public route: KnockoutObservable; - + constructor(params: NavMenuParams) { // This viewmodel doesn't do anything except pass through the 'route' parameter to the view. // You could remove this viewmodel entirely, and define 'nav-menu' as a template-only component. diff --git a/templates/KnockoutSpa/ClientApp/router.ts b/templates/KnockoutSpa/ClientApp/router.ts index 6162eddb..526394e6 100644 --- a/templates/KnockoutSpa/ClientApp/router.ts +++ b/templates/KnockoutSpa/ClientApp/router.ts @@ -13,7 +13,7 @@ export class Router { public currentRoute = ko.observable({}); private disposeHistory: () => void; private clickEventListener: EventListener; - + constructor(history: HistoryModule.History, routes: Route[]) { // Reset and configure Crossroads so it matches routes and updates this.currentRoute crossroads.removeAllRoutes(); @@ -25,7 +25,7 @@ export class Router { }); }); - // Make history.js watch for navigation and notify Crossroads + // Make history.js watch for navigation and notify Crossroads this.disposeHistory = history.listen(location => crossroads.parse(location.pathname)); this.clickEventListener = evt => { let target: any = evt.target; @@ -37,10 +37,10 @@ export class Router { } } }; - + document.addEventListener('click', this.clickEventListener); } - + public dispose() { this.disposeHistory(); document.removeEventListener('click', this.clickEventListener); diff --git a/templates/KnockoutSpa/ClientApp/webpack-component-loader.ts b/templates/KnockoutSpa/ClientApp/webpack-component-loader.ts index d46ec827..10f13e8f 100644 --- a/templates/KnockoutSpa/ClientApp/webpack-component-loader.ts +++ b/templates/KnockoutSpa/ClientApp/webpack-component-loader.ts @@ -9,11 +9,11 @@ ko.components.loaders.unshift({ if (typeof componentConfig === 'function') { // It's a lazy-loaded Webpack bundle (componentConfig as any)(loadedModule => { - // Handle TypeScript-style default exports + // Handle TypeScript-style default exports if (loadedModule.__esModule && loadedModule.default) { loadedModule = loadedModule.default; } - + // Pass the loaded module to KO's default loader ko.components.defaultLoader.loadComponent(name, loadedModule, callback); }); diff --git a/templates/KnockoutSpa/project.json b/templates/KnockoutSpa/project.json index 608da4b5..66ff1470 100755 --- a/templates/KnockoutSpa/project.json +++ b/templates/KnockoutSpa/project.json @@ -56,7 +56,8 @@ "portable-net45+win8+dnxcore50", "portable-net45+win8" ] - } + }, + "Microsoft.DotNet.Watcher.Tools": "1.0.0-preview2-final" }, "frameworks": { diff --git a/templates/KnockoutSpa/webpack.config.js b/templates/KnockoutSpa/webpack.config.js index 5f8d2909..9762ae89 100644 --- a/templates/KnockoutSpa/webpack.config.js +++ b/templates/KnockoutSpa/webpack.config.js @@ -11,7 +11,7 @@ module.exports = merge({ }, module: { loaders: [ - { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'ts-loader' }, + { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'ts-loader?silent=true' }, { test: /\.html$/, loader: 'raw-loader' } ] }, diff --git a/templates/ReactReduxSpa/ClientApp/components/Home.tsx b/templates/ReactReduxSpa/ClientApp/components/Home.tsx index f681e2e5..50995624 100644 --- a/templates/ReactReduxSpa/ClientApp/components/Home.tsx +++ b/templates/ReactReduxSpa/ClientApp/components/Home.tsx @@ -15,7 +15,7 @@ export default class Home extends React.Component {
  • Client-side navigation. For example, click Counter then Back to return here.
  • Webpack dev middleware. In development mode, there's no need to run the webpack build tool. Your client-side resources are dynamically built on demand. Updates are available as soon as you modify any file.
  • -
  • Hot module replacement. In development mode, you don't even need to reload the page after making most changes. Within seconds of saving changes to files, rebuilt CSS and React components will be injected directly into your running application, preserving its live state.
  • +
  • Hot module replacement. In development mode, you don't even need to reload the page after making most changes. Within seconds of saving changes to files, rebuilt React components will be injected directly into your running application, preserving its live state.
  • Efficient production builds. In production mode, development-time features are disabled, and the webpack build tool produces minified static CSS and JavaScript files.
  • Server-side prerendering. To optimize startup time, your React application is first rendered on the server. The initial HTML and state is then transferred to the browser, where client-side code picks up where the server left off.
diff --git a/templates/ReactReduxSpa/ClientApp/store/WeatherForecasts.ts b/templates/ReactReduxSpa/ClientApp/store/WeatherForecasts.ts index b3e6b32a..3101a7dd 100644 --- a/templates/ReactReduxSpa/ClientApp/store/WeatherForecasts.ts +++ b/templates/ReactReduxSpa/ClientApp/store/WeatherForecasts.ts @@ -1,4 +1,4 @@ -import { fetch } from 'domain-task/fetch'; +import { fetch, addTask } from 'domain-task'; import { typeName, isActionType, Action, Reducer } from 'redux-typed'; import { ActionCreator } from './'; @@ -45,12 +45,13 @@ export const actionCreators = { requestWeatherForecasts: (startDateIndex: number): ActionCreator => (dispatch, getState) => { // Only load data if it's something we don't already have (and are not already loading) if (startDateIndex !== getState().weatherForecasts.startDateIndex) { - fetch(`/api/SampleData/WeatherForecasts?startDateIndex=${ startDateIndex }`) + let fetchTask = fetch(`/api/SampleData/WeatherForecasts?startDateIndex=${ startDateIndex }`) .then(response => response.json()) .then((data: WeatherForecast[]) => { dispatch(new ReceiveWeatherForecasts(startDateIndex, data)); }); + addTask(fetchTask); // Ensure server-side prerendering waits for this to complete dispatch(new RequestWeatherForecasts(startDateIndex)); } } diff --git a/templates/ReactReduxSpa/package.json b/templates/ReactReduxSpa/package.json index 1e7c3e4a..4070c8af 100644 --- a/templates/ReactReduxSpa/package.json +++ b/templates/ReactReduxSpa/package.json @@ -23,7 +23,7 @@ "aspnet-prerendering": "^1.0.2", "aspnet-webpack": "^1.0.2", "babel-core": "^6.5.2", - "domain-task": "^1.0.0", + "domain-task": "^2.0.0", "react": "^15.0.1", "react-dom": "^15.0.1", "react-redux": "^4.4.4", diff --git a/templates/ReactReduxSpa/project.json b/templates/ReactReduxSpa/project.json index 467e718e..f768ecaf 100755 --- a/templates/ReactReduxSpa/project.json +++ b/templates/ReactReduxSpa/project.json @@ -56,7 +56,8 @@ "portable-net45+win8+dnxcore50", "portable-net45+win8" ] - } + }, + "Microsoft.DotNet.Watcher.Tools": "1.0.0-preview2-final" }, "frameworks": { diff --git a/templates/ReactReduxSpa/webpack.config.js b/templates/ReactReduxSpa/webpack.config.js index b3657570..44a2ec42 100644 --- a/templates/ReactReduxSpa/webpack.config.js +++ b/templates/ReactReduxSpa/webpack.config.js @@ -14,7 +14,7 @@ module.exports = merge({ module: { loaders: [ { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'babel-loader' }, - { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'ts-loader' }, + { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'ts-loader?silent=true' }, { test: /\.css/, loader: extractCSS.extract(['css']) } ] }, diff --git a/templates/ReactSpa/ClientApp/components/Home.tsx b/templates/ReactSpa/ClientApp/components/Home.tsx index d60da060..9f68e05c 100644 --- a/templates/ReactSpa/ClientApp/components/Home.tsx +++ b/templates/ReactSpa/ClientApp/components/Home.tsx @@ -15,7 +15,7 @@ export class Home extends React.Component {
  • Client-side navigation. For example, click Counter then Back to return here.
  • Webpack dev middleware. In development mode, there's no need to run the webpack build tool. Your client-side resources are dynamically built on demand. Updates are available as soon as you modify any file.
  • -
  • Hot module replacement. In development mode, you don't even need to reload the page after making most changes. Within seconds of saving changes to files, rebuilt CSS and React components will be injected directly into your running application, preserving its live state.
  • +
  • Hot module replacement. In development mode, you don't even need to reload the page after making most changes. Within seconds of saving changes to files, rebuilt React components will be injected directly into your running application, preserving its live state.
  • Efficient production builds. In production mode, development-time features are disabled, and the webpack build tool produces minified static CSS and JavaScript files.

Going further

diff --git a/templates/ReactSpa/project.json b/templates/ReactSpa/project.json index 467e718e..f768ecaf 100755 --- a/templates/ReactSpa/project.json +++ b/templates/ReactSpa/project.json @@ -56,7 +56,8 @@ "portable-net45+win8+dnxcore50", "portable-net45+win8" ] - } + }, + "Microsoft.DotNet.Watcher.Tools": "1.0.0-preview2-final" }, "frameworks": { diff --git a/templates/ReactSpa/webpack.config.js b/templates/ReactSpa/webpack.config.js index bb02f421..3cda8d89 100644 --- a/templates/ReactSpa/webpack.config.js +++ b/templates/ReactSpa/webpack.config.js @@ -12,7 +12,7 @@ module.exports = merge({ module: { loaders: [ { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'babel-loader' }, - { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'ts-loader' } + { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'ts-loader?silent=true' } ] }, entry: { diff --git a/templates/WebApplicationBasic/webpack.config.js b/templates/WebApplicationBasic/webpack.config.js index 030fe54b..129d22ce 100644 --- a/templates/WebApplicationBasic/webpack.config.js +++ b/templates/WebApplicationBasic/webpack.config.js @@ -13,7 +13,7 @@ module.exports = merge({ }, module: { loaders: [ - { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'ts-loader' }, + { test: /\.ts(x?)$/, include: /ClientApp/, loader: 'ts-loader?silent=true' }, { test: /\.css/, loader: extractCSS.extract(['css']) } ] }, diff --git a/templates/yeoman/src/generator/package.json b/templates/yeoman/src/generator/package.json index 2a340a9d..ad60454d 100644 --- a/templates/yeoman/src/generator/package.json +++ b/templates/yeoman/src/generator/package.json @@ -1,6 +1,6 @@ { "name": "generator-aspnetcore-spa", - "version": "0.2.1", + "version": "0.2.2", "description": "Single-Page App templates for ASP.NET Core", "author": "Microsoft", "license": "Apache-2.0",