UNPKG

12.6 kBPlain TextView Raw
1import * as connect from 'connect';
2import * as webpack from 'webpack';
3import * as url from 'url';
4import * as fs from 'fs';
5import * as path from 'path';
6import { requireNewCopy } from './RequireNewCopy';
7
8export type CreateDevServerResult = {
9 Port: number,
10 PublicPaths: string[],
11 PublicPath: string // For backward compatibility with older verions of Microsoft.AspNetCore.SpaServices. Will be removed soon.
12};
13
14export interface CreateDevServerCallback {
15 (error: any, result: CreateDevServerResult): void;
16}
17
18// These are the options passed by WebpackDevMiddleware.cs
19interface CreateDevServerOptions {
20 webpackConfigPath: string;
21 suppliedOptions: DevServerOptions;
22}
23
24// These are the options configured in C# and then JSON-serialized, hence the C#-style naming
25interface DevServerOptions {
26 HotModuleReplacement: boolean;
27 HotModuleReplacementServerPort: number;
28 ReactHotModuleReplacement: boolean;
29}
30
31function attachWebpackDevMiddleware(app: any, webpackConfig: webpack.Configuration, enableHotModuleReplacement: boolean, enableReactHotModuleReplacement: boolean, hmrEndpoint: string) {
32 // Build the final Webpack config based on supplied options
33 if (enableHotModuleReplacement) {
34 // For this, we only support the key/value config format, not string or string[], since
35 // those ones don't clearly indicate what the resulting bundle name will be
36 const entryPoints = webpackConfig.entry;
37 const isObjectStyleConfig = entryPoints
38 && typeof entryPoints === 'object'
39 && !(entryPoints instanceof Array);
40 if (!isObjectStyleConfig) {
41 throw new Error('To use HotModuleReplacement, your webpack config must specify an \'entry\' value as a key-value object (e.g., "entry: { main: \'ClientApp/boot-client.ts\' }")');
42 }
43
44 // Augment all entry points so they support HMR (unless they already do)
45 Object.getOwnPropertyNames(entryPoints).forEach(entryPointName => {
46 const webpackHotMiddlewareEntryPoint = 'webpack-hot-middleware/client';
47 const webpackHotMiddlewareOptions = `?path=` + encodeURIComponent(hmrEndpoint);
48 if (typeof entryPoints[entryPointName] === 'string') {
49 entryPoints[entryPointName] = [webpackHotMiddlewareEntryPoint + webpackHotMiddlewareOptions, entryPoints[entryPointName]];
50 } else if (firstIndexOfStringStartingWith(entryPoints[entryPointName], webpackHotMiddlewareEntryPoint) < 0) {
51 entryPoints[entryPointName].unshift(webpackHotMiddlewareEntryPoint + webpackHotMiddlewareOptions);
52 }
53
54 // Now also inject eventsource polyfill so this can work on IE/Edge (unless it's already there)
55 // To avoid this being a breaking change for everyone who uses aspnet-webpack, we only do this if you've
56 // referenced event-source-polyfill in your package.json. Note that having event-source-polyfill available
57 // on the server in node_modules doesn't imply that you've also included it in your client-side bundle,
58 // but the converse is true (if it's not in node_modules, then you obviously aren't trying to use it at
59 // all, so it would definitely not work to take a dependency on it).
60 const eventSourcePolyfillEntryPoint = 'event-source-polyfill';
61 if (npmModuleIsPresent(eventSourcePolyfillEntryPoint)) {
62 const entryPointsArray: string[] = entryPoints[entryPointName]; // We know by now that it's an array, because if it wasn't, we already wrapped it in one
63 if (entryPointsArray.indexOf(eventSourcePolyfillEntryPoint) < 0) {
64 const webpackHmrIndex = firstIndexOfStringStartingWith(entryPointsArray, webpackHotMiddlewareEntryPoint);
65 if (webpackHmrIndex < 0) {
66 // This should not be possible, since we just added it if it was missing
67 throw new Error('Cannot find ' + webpackHotMiddlewareEntryPoint + ' in entry points array: ' + entryPointsArray);
68 }
69
70 // Insert the polyfill just before the HMR entrypoint
71 entryPointsArray.splice(webpackHmrIndex, 0, eventSourcePolyfillEntryPoint);
72 }
73 }
74 });
75
76 webpackConfig.plugins = [].concat(webpackConfig.plugins || []); // Be sure not to mutate the original array, as it might be shared
77 webpackConfig.plugins.push(
78 new webpack.HotModuleReplacementPlugin()
79 );
80
81 // Set up React HMR support if requested. This requires the 'aspnet-webpack-react' package.
82 if (enableReactHotModuleReplacement) {
83 let aspNetWebpackReactModule: any;
84 try {
85 aspNetWebpackReactModule = require('aspnet-webpack-react');
86 } catch(ex) {
87 throw new Error('ReactHotModuleReplacement failed because of an error while loading \'aspnet-webpack-react\'. Error was: ' + ex.stack);
88 }
89
90 aspNetWebpackReactModule.addReactHotModuleReplacementBabelTransform(webpackConfig);
91 }
92 }
93
94 // Attach Webpack dev middleware and optional 'hot' middleware
95 const compiler = webpack(webpackConfig);
96 app.use(require('webpack-dev-middleware')(compiler, {
97 noInfo: true,
98 publicPath: webpackConfig.output.publicPath
99 }));
100
101 // After each compilation completes, copy the in-memory filesystem to disk.
102 // This is needed because the debuggers in both VS and VS Code assume that they'll be able to find
103 // the compiled files on the local disk (though it would be better if they got the source file from
104 // the browser they are debugging, which would be more correct and make this workaround unnecessary).
105 // Without this, Webpack plugins like HMR that dynamically modify the compiled output in the dev
106 // middleware's in-memory filesystem only (and not on disk) would confuse the debugger, because the
107 // file on disk wouldn't match the file served to the browser, and the source map line numbers wouldn't
108 // match up. Breakpoints would either not be hit, or would hit the wrong lines.
109 (compiler as any).plugin('done', stats => {
110 copyRecursiveToRealFsSync(compiler.outputFileSystem, '/', [/\.hot-update\.(js|json)$/]);
111 });
112
113 if (enableHotModuleReplacement) {
114 let webpackHotMiddlewareModule;
115 try {
116 webpackHotMiddlewareModule = require('webpack-hot-middleware');
117 } catch (ex) {
118 throw new Error('HotModuleReplacement failed because of an error while loading \'webpack-hot-middleware\'. Error was: ' + ex.stack);
119 }
120 app.use(webpackHotMiddlewareModule(compiler));
121 }
122}
123
124function copyRecursiveToRealFsSync(from: typeof fs, rootDir: string, exclude: RegExp[]) {
125 from.readdirSync(rootDir).forEach(filename => {
126 const fullPath = pathJoinSafe(rootDir, filename);
127 const shouldExclude = exclude.filter(re => re.test(fullPath)).length > 0;
128 if (!shouldExclude) {
129 const fileStat = from.statSync(fullPath);
130 if (fileStat.isFile()) {
131 const fileBuf = from.readFileSync(fullPath);
132 fs.writeFileSync(fullPath, fileBuf);
133 } else if (fileStat.isDirectory()) {
134 if (!fs.existsSync(fullPath)) {
135 fs.mkdirSync(fullPath);
136 }
137 copyRecursiveToRealFsSync(from, fullPath, exclude);
138 }
139 }
140 });
141}
142
143function pathJoinSafe(rootPath: string, filePath: string) {
144 // On Windows, MemoryFileSystem's readdirSync output produces directory entries like 'C:'
145 // which then trigger errors if you call statSync for them. Avoid this by detecting drive
146 // names at the root, and adding a backslash (so 'C:' becomes 'C:\', which works).
147 if (rootPath === '/' && path.sep === '\\' && filePath.match(/^[a-z0-9]+\:$/i)) {
148 return filePath + '\\';
149 } else {
150 return path.join(rootPath, filePath);
151 }
152}
153
154function beginWebpackWatcher(webpackConfig: webpack.Configuration) {
155 const compiler = webpack(webpackConfig);
156 compiler.watch({ /* watchOptions */ }, (err, stats) => {
157 // The default error reporter is fine for now, but could be customized here in the future if desired
158 });
159}
160
161export function createWebpackDevServer(callback: CreateDevServerCallback, optionsJson: string) {
162 const options: CreateDevServerOptions = JSON.parse(optionsJson);
163
164 // Read the webpack config's export, and normalize it into the more general 'array of configs' format
165 let webpackConfigArray: webpack.Configuration[] = requireNewCopy(options.webpackConfigPath);
166 if (!(webpackConfigArray instanceof Array)) {
167 webpackConfigArray = [webpackConfigArray as webpack.Configuration];
168 }
169
170 const enableHotModuleReplacement = options.suppliedOptions.HotModuleReplacement;
171 const enableReactHotModuleReplacement = options.suppliedOptions.ReactHotModuleReplacement;
172 if (enableReactHotModuleReplacement && !enableHotModuleReplacement) {
173 callback('To use ReactHotModuleReplacement, you must also enable the HotModuleReplacement option.', null);
174 return;
175 }
176
177 // The default value, 0, means 'choose randomly'
178 const suggestedHMRPortOrZero = options.suppliedOptions.HotModuleReplacementServerPort || 0;
179
180 const app = connect();
181 const listener = app.listen(suggestedHMRPortOrZero, () => {
182 try {
183 // For each webpack config that specifies a public path, add webpack dev middleware for it
184 const normalizedPublicPaths: string[] = [];
185 webpackConfigArray.forEach(webpackConfig => {
186 if (webpackConfig.target === 'node') {
187 // For configs that target Node, it's meaningless to set up an HTTP listener, since
188 // Node isn't going to load those modules over HTTP anyway. It just loads them directly
189 // from disk. So the most relevant thing we can do with such configs is just write
190 // updated builds to disk, just like "webpack --watch".
191 beginWebpackWatcher(webpackConfig);
192 } else {
193 // For configs that target browsers, we can set up an HTTP listener, and dynamically
194 // modify the config to enable HMR etc. This just requires that we have a publicPath.
195 const publicPath = (webpackConfig.output.publicPath || '').trim();
196 if (!publicPath) {
197 throw new Error('To use the Webpack dev server, you must specify a value for \'publicPath\' on the \'output\' section of your webpack config (for any configuration that targets browsers)');
198 }
199 normalizedPublicPaths.push(removeTrailingSlash(publicPath));
200
201 const hmrEndpoint = `http://localhost:${listener.address().port}/__webpack_hmr`;
202 attachWebpackDevMiddleware(app, webpackConfig, enableHotModuleReplacement, enableReactHotModuleReplacement, hmrEndpoint);
203 }
204 });
205
206 // Tell the ASP.NET app what addresses we're listening on, so that it can proxy requests here
207 callback(null, {
208 Port: listener.address().port,
209 PublicPaths: normalizedPublicPaths,
210
211 // For back-compatibility with older versions of Microsoft.AspNetCore.SpaServices, in the case where
212 // you have exactly one webpackConfigArray entry. This will be removed soon.
213 PublicPath: normalizedPublicPaths[0]
214 });
215 } catch (ex) {
216 callback(ex.stack, null);
217 }
218 });
219}
220
221function removeTrailingSlash(str: string) {
222 if (str.lastIndexOf('/') === str.length - 1) {
223 str = str.substring(0, str.length - 1);
224 }
225
226 return str;
227}
228
229function getPath(publicPath: string) {
230 return url.parse(publicPath).path;
231}
232
233function firstIndexOfStringStartingWith(array: string[], prefixToFind: string) {
234 for (let index = 0; index < array.length; index++) {
235 const candidate = array[index];
236 if ((typeof candidate === 'string') && (candidate.substring(0, prefixToFind.length) === prefixToFind)) {
237 return index;
238 }
239 }
240
241 return -1; // Not found
242}
243
244function npmModuleIsPresent(moduleName: string) {
245 try {
246 require.resolve(moduleName);
247 return true;
248 } catch (ex) {
249 return false;
250 }
251}