diff --git a/samples/react/MusicStore/ReactApp/boot-server.tsx b/samples/react/MusicStore/ReactApp/boot-server.tsx
index a3d7c81..c0df3ab 100644
--- a/samples/react/MusicStore/ReactApp/boot-server.tsx
+++ b/samples/react/MusicStore/ReactApp/boot-server.tsx
@@ -3,34 +3,38 @@ import { Provider } from 'react-redux';
import { renderToString } from 'react-dom/server';
import { match, RouterContext } from 'react-router';
import createMemoryHistory from 'history/lib/createMemoryHistory';
-React;
-
import { routes } from './routes';
import configureStore from './configureStore';
-import { ApplicationState } from './store';
+React;
-export default function (params: any, callback: (err: any, result: { html: string, state: any }) => void) {
- const { location } = params;
- match({ routes, location }, (error, redirectLocation, renderProps: any) => {
- try {
+export default function (params: any): Promise<{ html: string }> {
+ return new Promise<{ html: string, globals: { [key: string]: any } }>((resolve, reject) => {
+ // Match the incoming request against the list of client-side routes
+ match({ routes, location: params.location }, (error, redirectLocation, renderProps: any) => {
if (error) {
throw error;
}
+ // Build an instance of the application
const history = createMemoryHistory(params.url);
- const store = params.state as Redux.Store || configureStore(history);
- let html = renderToString(
+ const store = configureStore(history);
+ const app = (
-
+
);
-
- // Also serialise the Redux state so the client can pick up where the server left off
- html += ``;
-
- callback(null, { html, state: store });
- } catch (error) {
- callback(error, null);
- }
+
+ // Perform an initial render that will cause any async tasks (e.g., data access) to begin
+ renderToString(app);
+
+ // Once the tasks are done, we can perform the final render
+ // We also send the redux store state, so the client can continue execution where the server left off
+ params.domainTasks.then(() => {
+ resolve({
+ html: renderToString(app),
+ globals: { initialReduxState: store.getState() }
+ });
+ }, reject); // Also propagate any errors back into the host application
+ });
});
}
diff --git a/samples/react/MusicStore/ReactApp/boot.tsx b/samples/react/MusicStore/ReactApp/boot.tsx
index 6a8b6fc..ad8f367 100644
--- a/samples/react/MusicStore/ReactApp/boot.tsx
+++ b/samples/react/MusicStore/ReactApp/boot.tsx
@@ -8,8 +8,10 @@ import './styles/styles.css';
import 'bootstrap/dist/css/bootstrap.css';
import configureStore from './configureStore';
import { routes } from './routes';
+import { ApplicationState } from './store';
-const store = configureStore(browserHistory);
+const initialState = (window as any).initialReduxState as ApplicationState;
+const store = configureStore(browserHistory, initialState);
ReactDOM.render(
diff --git a/samples/react/MusicStore/ReactApp/fx/render-server.js b/samples/react/MusicStore/ReactApp/fx/render-server.js
deleted file mode 100644
index 5ea8c35..0000000
--- a/samples/react/MusicStore/ReactApp/fx/render-server.js
+++ /dev/null
@@ -1,54 +0,0 @@
-require('./require-ts-babel')(); // Enable loading TS/TSX/JSX/ES2015 modules
-var url = require('url');
-var domainTask = require('domain-task');
-var baseUrl = require('domain-task/fetch').baseUrl;
-
-function render(bootModulePath, absoluteRequestUrl, requestPathAndQuery, callback) {
- var bootFunc = require(bootModulePath);
- if (typeof bootFunc !== 'function') {
- bootFunc = bootFunc.default;
- }
- if (typeof bootFunc !== 'function') {
- throw new Error('The module at ' + bootModulePath + ' must export a default function, otherwise we don\'t know how to invoke it.')
- }
-
- var params = {
- location: url.parse(requestPathAndQuery),
- url: requestPathAndQuery,
- state: undefined
- };
-
- // Open a new domain that can track all the async tasks commenced during first render
- domainTask.run(function() {
- baseUrl(absoluteRequestUrl);
-
- // Since route matching is asynchronous, add the rendering itself to the list of tasks we're awaiting
- domainTask.addTask(new Promise(function (resolve, reject) {
- // Now actually perform the first render that will match a route and commence associated tasks
- bootFunc(params, function(error, result) {
- if (error) {
- reject(error);
- } else {
- // The initial 'loading' state HTML is irrelevant - we only want to capture the state
- // so we can use it to perform a real render once all data is loaded
- params.state = result.state;
- resolve();
- }
- });
- }));
- }, function(error) {
- // By now, all the data should be loaded, so we can render for real based on the state now
- // TODO: Add an optimisation where, if domain-tasks had no outstanding tasks at the end of
- // the previous render, we don't re-render (we can use the previous html and state).
- if (error) { console.error(error); throw error; }
- bootFunc(params, callback);
- });
-}
-
-render('../boot-server.tsx', 'http://localhost:5000', '/', (err, html) => {
- if (err) {
- throw err;
- }
-
- console.log(html);
-});
diff --git a/samples/react/MusicStore/ReactApp/fx/require-ts-babel.js b/samples/react/MusicStore/ReactApp/fx/require-ts-babel.js
deleted file mode 100644
index 8656138..0000000
--- a/samples/react/MusicStore/ReactApp/fx/require-ts-babel.js
+++ /dev/null
@@ -1,44 +0,0 @@
-var fs = require('fs');
-var ts = require('ntypescript');
-var babelCore = require('babel-core');
-var resolveBabelRc = require('babel-loader/lib/resolve-rc'); // If this ever breaks, we can easily scan up the directory hierarchy ourselves
-var origJsLoader = require.extensions['.js'];
-
-function resolveBabelOptions(relativeToFilename) {
- var babelRcText = resolveBabelRc(relativeToFilename);
- return babelRcText ? JSON.parse(babelRcText) : {};
-}
-
-function loadViaTypeScript(module, filename) {
- // First perform a minimal transpilation from TS code to ES2015. This is very fast (doesn't involve type checking)
- // and is unlikely to need any special compiler options
- var src = fs.readFileSync(filename, 'utf8');
- var compilerOptions = { jsx: ts.JsxEmit.Preserve, module: ts.ModuleKind.ES2015, target: ts.ScriptTarget.ES6 };
- var es6Code = ts.transpile(src, compilerOptions, 'test.tsx', /* diagnostics */ []);
-
- // Second, process the ES2015 via Babel. We have to do this (instead of going directly from TS to ES5) because
- // TypeScript's ES5 output isn't exactly compatible with Node-style CommonJS modules. The main issue is with
- // resolving default exports - https://github.com/Microsoft/TypeScript/issues/2719
- var es5Code = babelCore.transform(es6Code, resolveBabelOptions(filename)).code;
- return module._compile(es5Code, filename);
-}
-
-function loadViaBabel(module, filename) {
- // Assume that all the app's own code is ES2015+ (optionally with JSX), but that none of the node_modules are.
- // The distinction is important because ES2015+ forces strict mode, and it may break ES3/5 if you try to run it in strict
- // mode when the developer didn't expect that (e.g., current versions of underscore.js can't be loaded in strict mode).
- var useBabel = filename.indexOf('node_modules') < 0;
- if (useBabel) {
- var transformedFile = babelCore.transformFileSync(filename, resolveBabelOptions(filename));
- return module._compile(transformedFile.code, filename);
- } else {
- return origJsLoader.apply(this, arguments);
- }
-}
-
-module.exports = function register() {
- require.extensions['.js'] = loadViaBabel;
- require.extensions['.jsx'] = loadViaBabel;
- require.extensions['.ts'] = loadViaTypeScript;
- require.extensions['.tsx'] = loadViaTypeScript;
-}
diff --git a/samples/react/MusicStore/Startup.cs b/samples/react/MusicStore/Startup.cs
index c36fb56..3e601f5 100755
--- a/samples/react/MusicStore/Startup.cs
+++ b/samples/react/MusicStore/Startup.cs
@@ -1,13 +1,9 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
using AutoMapper;
using Microsoft.AspNet.Authorization;
using Microsoft.AspNet.Builder;
using Microsoft.AspNet.Hosting;
using Microsoft.AspNet.Identity.EntityFramework;
-using Microsoft.AspNet.SpaServices;
+using Microsoft.AspNet.SpaServices.Webpack;
using Microsoft.Data.Entity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
diff --git a/samples/react/MusicStore/Views/Home/Index.cshtml b/samples/react/MusicStore/Views/Home/Index.cshtml
index db4dc4f..543e8ba 100755
--- a/samples/react/MusicStore/Views/Home/Index.cshtml
+++ b/samples/react/MusicStore/Views/Home/Index.cshtml
@@ -2,7 +2,7 @@
ViewData["Title"] = "Home Page";
}
-Loading...
+
@section scripts {
diff --git a/samples/react/MusicStore/Views/_ViewImports.cshtml b/samples/react/MusicStore/Views/_ViewImports.cshtml
index a07c195..89b9e8f 100755
--- a/samples/react/MusicStore/Views/_ViewImports.cshtml
+++ b/samples/react/MusicStore/Views/_ViewImports.cshtml
@@ -1,2 +1,3 @@
@using MusicStore
@addTagHelper "*, Microsoft.AspNet.Mvc.TagHelpers"
+@addTagHelper "*, Microsoft.AspNet.SpaServices"
diff --git a/src/Microsoft.AspNet.NodeServices/Content/Node/entrypoint-http.js b/src/Microsoft.AspNet.NodeServices/Content/Node/entrypoint-http.js
index da81202..d833644 100644
--- a/src/Microsoft.AspNet.NodeServices/Content/Node/entrypoint-http.js
+++ b/src/Microsoft.AspNet.NodeServices/Content/Node/entrypoint-http.js
@@ -4,7 +4,7 @@ var http = require('http');
var path = require('path');
var requestedPortOrZero = parseInt(process.argv[2]) || 0; // 0 means 'let the OS decide'
-autoQuitOnFileChange(process.cwd(), ['.js', '.json', '.html']);
+autoQuitOnFileChange(process.cwd(), ['.js', '.jsx', '.ts', '.tsx', '.json', '.html']);
var server = http.createServer(function(req, res) {
readRequestBodyAsJson(req, function(bodyJson) {
diff --git a/src/Microsoft.AspNet.ReactServices/ReactRenderer.cs b/src/Microsoft.AspNet.ReactServices/ReactRenderer.cs
deleted file mode 100644
index e97f39e..0000000
--- a/src/Microsoft.AspNet.ReactServices/ReactRenderer.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using System.Threading.Tasks;
-using Microsoft.AspNet.NodeServices;
-
-namespace Microsoft.AspNet.ReactServices
-{
- public static class ReactRenderer
- {
- private static StringAsTempFile nodeScript;
-
- static ReactRenderer() {
- // Consider populating this lazily
- var script = EmbeddedResourceReader.Read(typeof (ReactRenderer), "/Content/Node/react-rendering.js");
- nodeScript = new StringAsTempFile(script); // Will be cleaned up on process exit
- }
-
- public static async Task RenderToString(INodeServices nodeServices, string componentModuleName, string componentExportName, string requestUrl) {
- return await nodeServices.InvokeExport(nodeScript.FileName, "renderToString", new {
- moduleName = componentModuleName,
- exportName = componentExportName,
- requestUrl = requestUrl
- });
- }
- }
-}
diff --git a/src/Microsoft.AspNet.SpaServices/Content/Node/prerenderer.js b/src/Microsoft.AspNet.SpaServices/Content/Node/prerenderer.js
new file mode 100644
index 0000000..23644a0
--- /dev/null
+++ b/src/Microsoft.AspNet.SpaServices/Content/Node/prerenderer.js
@@ -0,0 +1,135 @@
+// -----------------------------------------------------------------------------------------
+// Prepare the Node environment to support loading .jsx/.ts/.tsx files without needing precompilation,
+// since that's such a common scenario. In the future, this might become a config option.
+// This is bundled in with the actual prerendering logic below just to simplify the initialization
+// logic (we can't have cross-file imports, because these files don't exist on disk until the
+// StringAsTempFile utility puts them there temporarily).
+
+// TODO: Consider some general method for checking if you have all the necessary NPM modules installed,
+// and if not, giving an error that tells you what command to execute to install the missing ones.
+var fs = require('fs');
+var ts = require('ntypescript');
+var babelCore = require('babel-core');
+var resolveBabelRc = require('babel-loader/lib/resolve-rc'); // If this ever breaks, we can easily scan up the directory hierarchy ourselves
+var origJsLoader = require.extensions['.js'];
+
+function resolveBabelOptions(relativeToFilename) {
+ var babelRcText = resolveBabelRc(relativeToFilename);
+ return babelRcText ? JSON.parse(babelRcText) : {};
+}
+
+function loadViaTypeScript(module, filename) {
+ // First perform a minimal transpilation from TS code to ES2015. This is very fast (doesn't involve type checking)
+ // and is unlikely to need any special compiler options
+ var src = fs.readFileSync(filename, 'utf8');
+ var compilerOptions = { jsx: ts.JsxEmit.Preserve, module: ts.ModuleKind.ES2015, target: ts.ScriptTarget.ES6 };
+ var es6Code = ts.transpile(src, compilerOptions, 'test.tsx', /* diagnostics */ []);
+
+ // Second, process the ES2015 via Babel. We have to do this (instead of going directly from TS to ES5) because
+ // TypeScript's ES5 output isn't exactly compatible with Node-style CommonJS modules. The main issue is with
+ // resolving default exports - https://github.com/Microsoft/TypeScript/issues/2719
+ var es5Code = babelCore.transform(es6Code, resolveBabelOptions(filename)).code;
+ return module._compile(es5Code, filename);
+}
+
+function loadViaBabel(module, filename) {
+ // Assume that all the app's own code is ES2015+ (optionally with JSX), but that none of the node_modules are.
+ // The distinction is important because ES2015+ forces strict mode, and it may break ES3/5 if you try to run it in strict
+ // mode when the developer didn't expect that (e.g., current versions of underscore.js can't be loaded in strict mode).
+ var useBabel = filename.indexOf('node_modules') < 0;
+ if (useBabel) {
+ var transformedFile = babelCore.transformFileSync(filename, resolveBabelOptions(filename));
+ return module._compile(transformedFile.code, filename);
+ } else {
+ return origJsLoader.apply(this, arguments);
+ }
+}
+
+function register() {
+ require.extensions['.js'] = loadViaBabel;
+ require.extensions['.jsx'] = loadViaBabel;
+ require.extensions['.ts'] = loadViaTypeScript;
+ require.extensions['.tsx'] = loadViaTypeScript;
+};
+
+register();
+
+// -----------------------------------------------------------------------------------------
+// Rendering
+
+var url = require('url');
+var path = require('path');
+var domain = require('domain');
+var domainTask = require('domain-task');
+var baseUrl = require('domain-task/fetch').baseUrl;
+
+function findBootFunc(bootModulePath, bootModuleExport) {
+ var resolvedPath = path.resolve(process.cwd(), bootModulePath);
+ var bootFunc = require(resolvedPath);
+ if (bootModuleExport) {
+ bootFunc = bootFunc[bootModuleExport];
+ } else if (typeof bootFunc !== 'function') {
+ bootFunc = bootFunc.default; // TypeScript sometimes uses this name for default exports
+ }
+ if (typeof bootFunc !== 'function') {
+ if (bootModuleExport) {
+ throw new Error('The module at ' + bootModulePath + ' has no function export named ' + bootModuleExport + '.');
+ } else {
+ throw new Error('The module at ' + bootModulePath + ' does not export a default function, and you have not specified which export to invoke.');
+ }
+ }
+
+ return bootFunc;
+}
+
+function renderToString(callback, bootModulePath, bootModuleExport, absoluteRequestUrl, requestPathAndQuery) {
+ var bootFunc = findBootFunc(bootModulePath, bootModuleExport);
+
+ // Prepare a promise that will represent the completion of all domain tasks in this execution context.
+ // The boot code will wait for this before performing its final render.
+ var domainTaskCompletionPromiseResolve;
+ var domainTaskCompletionPromise = new Promise(function (resolve, reject) {
+ domainTaskCompletionPromiseResolve = resolve;
+ });
+ var params = {
+ location: url.parse(requestPathAndQuery),
+ url: requestPathAndQuery,
+ domainTasks: domainTaskCompletionPromise
+ };
+
+ // Open a new domain that can track all the async tasks involved in the app's execution
+ domainTask.run(function() {
+ // Workaround for Node bug where native Promise continuations lose their domain context
+ // (https://github.com/nodejs/node-v0.x-archive/issues/8648)
+ bindPromiseContinuationsToDomain(domainTaskCompletionPromise, domain.active);
+
+ // Make the base URL available to the 'domain-tasks/fetch' helper within this execution context
+ baseUrl(absoluteRequestUrl);
+
+ // Actually perform the rendering
+ bootFunc(params).then(function(successResult) {
+ callback(null, { html: successResult.html, globals: successResult.globals });
+ }, function(error) {
+ callback(error, null);
+ });
+ }, function allDomainTasksCompleted(error) {
+ // There are no more ongoing domain tasks (typically data access operations), so we can resolve
+ // the domain tasks promise which notifies the boot code that it can do its final render.
+ if (error) {
+ callback(error, null);
+ } else {
+ domainTaskCompletionPromiseResolve();
+ }
+ });
+}
+
+function bindPromiseContinuationsToDomain(promise, domainInstance) {
+ var originalThen = promise.then;
+ promise.then = function then(resolve, reject) {
+ if (typeof resolve === 'function') { resolve = domainInstance.bind(resolve); }
+ if (typeof reject === 'function') { reject = domainInstance.bind(reject); }
+ return originalThen.call(this, resolve, reject);
+ };
+}
+
+module.exports.renderToString = renderToString;
diff --git a/src/Microsoft.AspNet.ReactServices/ReactPrerenderTagHelper.cs b/src/Microsoft.AspNet.SpaServices/Prerendering/PrerenderTagHelper.cs
similarity index 55%
rename from src/Microsoft.AspNet.ReactServices/ReactPrerenderTagHelper.cs
rename to src/Microsoft.AspNet.SpaServices/Prerendering/PrerenderTagHelper.cs
index 6d2b761..5f3cc8b 100644
--- a/src/Microsoft.AspNet.ReactServices/ReactPrerenderTagHelper.cs
+++ b/src/Microsoft.AspNet.SpaServices/Prerendering/PrerenderTagHelper.cs
@@ -1,19 +1,22 @@
using System;
+using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNet.Http;
+using Microsoft.AspNet.Http.Extensions;
using Microsoft.AspNet.NodeServices;
using Microsoft.AspNet.Razor.TagHelpers;
using Microsoft.Extensions.PlatformAbstractions;
+using Newtonsoft.Json;
-namespace Microsoft.AspNet.ReactServices
+namespace Microsoft.AspNet.SpaServices.Prerendering
{
[HtmlTargetElement(Attributes = PrerenderModuleAttributeName)]
- public class ReactPrerenderTagHelper : TagHelper
+ public class PrerenderTagHelper : TagHelper
{
static INodeServices fallbackNodeServices; // Used only if no INodeServices was registered with DI
- const string PrerenderModuleAttributeName = "asp-react-prerender-module";
- const string PrerenderExportAttributeName = "asp-react-prerender-export";
+ const string PrerenderModuleAttributeName = "asp-prerender-module";
+ const string PrerenderExportAttributeName = "asp-prerender-export";
[HtmlAttributeName(PrerenderModuleAttributeName)]
public string ModuleName { get; set; }
@@ -24,7 +27,7 @@ namespace Microsoft.AspNet.ReactServices
private IHttpContextAccessor contextAccessor;
private INodeServices nodeServices;
- public ReactPrerenderTagHelper(IServiceProvider serviceProvider, IHttpContextAccessor contextAccessor)
+ public PrerenderTagHelper(IServiceProvider serviceProvider, IHttpContextAccessor contextAccessor)
{
this.contextAccessor = contextAccessor;
this.nodeServices = (INodeServices)serviceProvider.GetService(typeof (INodeServices)) ?? fallbackNodeServices;
@@ -40,12 +43,27 @@ namespace Microsoft.AspNet.ReactServices
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var request = this.contextAccessor.HttpContext.Request;
- var result = await ReactRenderer.RenderToString(
+ var result = await Prerenderer.RenderToString(
nodeServices: this.nodeServices,
componentModuleName: this.ModuleName,
componentExportName: this.ExportName,
- requestUrl: request.Path + request.QueryString.Value);
- output.Content.SetHtmlContent(result);
+ requestAbsoluteUrl: UriHelper.GetEncodedUrl(this.contextAccessor.HttpContext.Request),
+ requestPathAndQuery: request.Path + request.QueryString.Value);
+ output.Content.SetHtmlContent(result.Html);
+
+ // Also attach any specific globals to the 'window' object. This is useful for transferring
+ // general state between server and client.
+ if (result.Globals != null) {
+ var stringBuilder = new StringBuilder();
+ foreach (var property in result.Globals.Properties()) {
+ stringBuilder.AppendFormat("window.{0} = {1};",
+ property.Name,
+ property.Value.ToString(Formatting.None));
+ }
+ if (stringBuilder.Length > 0) {
+ output.PostElement.SetHtmlContent($"");
+ }
+ }
}
}
}
diff --git a/src/Microsoft.AspNet.SpaServices/Prerendering/Prerenderer.cs b/src/Microsoft.AspNet.SpaServices/Prerendering/Prerenderer.cs
new file mode 100644
index 0000000..b7558d4
--- /dev/null
+++ b/src/Microsoft.AspNet.SpaServices/Prerendering/Prerenderer.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNet.NodeServices;
+using Newtonsoft.Json.Linq;
+
+namespace Microsoft.AspNet.SpaServices.Prerendering
+{
+ public static class Prerenderer
+ {
+ private static Lazy nodeScript;
+
+ static Prerenderer() {
+ nodeScript = new Lazy(() => {
+ var script = EmbeddedResourceReader.Read(typeof(Prerenderer), "/Content/Node/prerenderer.js");
+ return new StringAsTempFile(script); // Will be cleaned up on process exit
+ });
+ }
+
+ public static async Task RenderToString(INodeServices nodeServices, string componentModuleName, string componentExportName, string requestAbsoluteUrl, string requestPathAndQuery) {
+ return await nodeServices.InvokeExport(nodeScript.Value.FileName, "renderToString",
+ /* bootModulePath */ componentModuleName,
+ /* bootModuleExport */ componentExportName,
+ /* absoluteRequestUrl */ requestAbsoluteUrl,
+ /* requestPathAndQuery */ requestPathAndQuery);
+ }
+ }
+
+ public class RenderToStringResult {
+ public string Html;
+ public JObject Globals;
+ }
+}
diff --git a/src/Microsoft.AspNet.SpaServices/SpaRouteConstraint.cs b/src/Microsoft.AspNet.SpaServices/Routing/SpaRouteConstraint.cs
similarity index 96%
rename from src/Microsoft.AspNet.SpaServices/SpaRouteConstraint.cs
rename to src/Microsoft.AspNet.SpaServices/Routing/SpaRouteConstraint.cs
index d4df4c7..dcbd6b3 100644
--- a/src/Microsoft.AspNet.SpaServices/SpaRouteConstraint.cs
+++ b/src/Microsoft.AspNet.SpaServices/Routing/SpaRouteConstraint.cs
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using Microsoft.AspNet.Http;
using Microsoft.AspNet.Routing;
-namespace Microsoft.AspNet.SpaServices
+namespace Microsoft.AspNet.SpaServices
{
internal class SpaRouteConstraint : IRouteConstraint
{
diff --git a/src/Microsoft.AspNet.SpaServices/SpaRouteExtensions.cs b/src/Microsoft.AspNet.SpaServices/Routing/SpaRouteExtensions.cs
similarity index 100%
rename from src/Microsoft.AspNet.SpaServices/SpaRouteExtensions.cs
rename to src/Microsoft.AspNet.SpaServices/Routing/SpaRouteExtensions.cs
diff --git a/src/Microsoft.AspNet.SpaServices/WebpackDevMiddleware.cs b/src/Microsoft.AspNet.SpaServices/Webpack/WebpackDevMiddleware.cs
similarity index 98%
rename from src/Microsoft.AspNet.SpaServices/WebpackDevMiddleware.cs
rename to src/Microsoft.AspNet.SpaServices/Webpack/WebpackDevMiddleware.cs
index 864df52..6c71800 100644
--- a/src/Microsoft.AspNet.SpaServices/WebpackDevMiddleware.cs
+++ b/src/Microsoft.AspNet.SpaServices/Webpack/WebpackDevMiddleware.cs
@@ -3,7 +3,7 @@ using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNet.NodeServices;
using Microsoft.AspNet.Proxy;
-using Microsoft.AspNet.SpaServices;
+using Microsoft.AspNet.SpaServices.Webpack;
using Microsoft.Extensions.PlatformAbstractions;
using Newtonsoft.Json;
diff --git a/src/Microsoft.AspNet.SpaServices/WebpackDevMiddlewareOptions.cs b/src/Microsoft.AspNet.SpaServices/Webpack/WebpackDevMiddlewareOptions.cs
similarity index 77%
rename from src/Microsoft.AspNet.SpaServices/WebpackDevMiddlewareOptions.cs
rename to src/Microsoft.AspNet.SpaServices/Webpack/WebpackDevMiddlewareOptions.cs
index 1fa5c56..a9510fc 100644
--- a/src/Microsoft.AspNet.SpaServices/WebpackDevMiddlewareOptions.cs
+++ b/src/Microsoft.AspNet.SpaServices/Webpack/WebpackDevMiddlewareOptions.cs
@@ -1,4 +1,4 @@
-namespace Microsoft.AspNet.SpaServices {
+namespace Microsoft.AspNet.SpaServices.Webpack {
public class WebpackDevMiddlewareOptions {
public bool HotModuleReplacement { get; set; }
public bool ReactHotModuleReplacement { get; set; }