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 const originalFileSystem = compiler.outputFileSystem;
97 app.use(require('webpack-dev-middleware')(compiler, {
98 noInfo: true,
99 publicPath: webpackConfig.output.publicPath
100 }));
101
102 // After each compilation completes, copy the in-memory filesystem to disk.
103 // This is needed because the debuggers in both VS and VS Code assume that they'll be able to find
104 // the compiled files on the local disk (though it would be better if they got the source file from
105 // the browser they are debugging, which would be more correct and make this workaround unnecessary).
106 // Without this, Webpack plugins like HMR that dynamically modify the compiled output in the dev
107 // middleware's in-memory filesystem only (and not on disk) would confuse the debugger, because the
108 // file on disk wouldn't match the file served to the browser, and the source map line numbers wouldn't
109 // match up. Breakpoints would either not be hit, or would hit the wrong lines.
110 (compiler as any).plugin('done', stats => {
111 copyRecursiveSync(compiler.outputFileSystem, originalFileSystem, '/', [/\.hot-update\.(js|json)$/]);
112 });
113
114 if (enableHotModuleReplacement) {
115 let webpackHotMiddlewareModule;
116 try {
117 webpackHotMiddlewareModule = require('webpack-hot-middleware');
118 } catch (ex) {
119 throw new Error('HotModuleReplacement failed because of an error while loading \'webpack-hot-middleware\'. Error was: ' + ex.stack);
120 }
121 app.use(webpackHotMiddlewareModule(compiler));
122 }
123}
124
125function copyRecursiveSync(from: typeof fs, to: typeof fs, rootDir: string, exclude: RegExp[]) {
126 from.readdirSync(rootDir).forEach(filename => {
127 const fullPath = pathJoinSafe(rootDir, filename);
128 const shouldExclude = exclude.filter(re => re.test(fullPath)).length > 0;
129 if (!shouldExclude) {
130 const fileStat = from.statSync(fullPath);
131 if (fileStat.isFile()) {
132 const fileBuf = from.readFileSync(fullPath);
133 to.writeFile(fullPath, fileBuf);
134 } else if (fileStat.isDirectory()) {
135 copyRecursiveSync(from, to, fullPath, exclude);
136 }
137 }
138 });
139}
140
141function pathJoinSafe(rootPath: string, filePath: string) {
142 // On Windows, MemoryFileSystem's readdirSync output produces directory entries like 'C:'
143 // which then trigger errors if you call statSync for them. Avoid this by detecting drive
144 // names at the root, and adding a backslash (so 'C:' becomes 'C:\', which works).
145 if (rootPath === '/' && path.sep === '\\' && filePath.match(/^[a-z0-9]+\:$/i)) {
146 return filePath + '\\';
147 } else {
148 return path.join(rootPath, filePath);
149 }
150}
151
152function beginWebpackWatcher(webpackConfig: webpack.Configuration) {
153 const compiler = webpack(webpackConfig);
154 compiler.watch({ /* watchOptions */ }, (err, stats) => {
155 // The default error reporter is fine for now, but could be customized here in the future if desired
156 });
157}
158
159export function createWebpackDevServer(callback: CreateDevServerCallback, optionsJson: string) {
160 const options: CreateDevServerOptions = JSON.parse(optionsJson);
161
162 // Read the webpack config's export, and normalize it into the more general 'array of configs' format
163 let webpackConfigArray: webpack.Configuration[] = requireNewCopy(options.webpackConfigPath);
164 if (!(webpackConfigArray instanceof Array)) {
165 webpackConfigArray = [webpackConfigArray as webpack.Configuration];
166 }
167
168 const enableHotModuleReplacement = options.suppliedOptions.HotModuleReplacement;
169 const enableReactHotModuleReplacement = options.suppliedOptions.ReactHotModuleReplacement;
170 if (enableReactHotModuleReplacement && !enableHotModuleReplacement) {
171 callback('To use ReactHotModuleReplacement, you must also enable the HotModuleReplacement option.', null);
172 return;
173 }
174
175 // The default value, 0, means 'choose randomly'
176 const suggestedHMRPortOrZero = options.suppliedOptions.HotModuleReplacementServerPort || 0;
177
178 const app = connect();
179 const listener = app.listen(suggestedHMRPortOrZero, () => {
180 try {
181 // For each webpack config that specifies a public path, add webpack dev middleware for it
182 const normalizedPublicPaths: string[] = [];
183 webpackConfigArray.forEach(webpackConfig => {
184 if (webpackConfig.target === 'node') {
185 // For configs that target Node, it's meaningless to set up an HTTP listener, since
186 // Node isn't going to load those modules over HTTP anyway. It just loads them directly
187 // from disk. So the most relevant thing we can do with such configs is just write
188 // updated builds to disk, just like "webpack --watch".
189 beginWebpackWatcher(webpackConfig);
190 } else {
191 // For configs that target browsers, we can set up an HTTP listener, and dynamically
192 // modify the config to enable HMR etc. This just requires that we have a publicPath.
193 const publicPath = (webpackConfig.output.publicPath || '').trim();
194 if (!publicPath) {
195 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)');
196 }
197 normalizedPublicPaths.push(removeTrailingSlash(publicPath));
198
199 const hmrEndpoint = `http://localhost:${listener.address().port}/__webpack_hmr`;
200 attachWebpackDevMiddleware(app, webpackConfig, enableHotModuleReplacement, enableReactHotModuleReplacement, hmrEndpoint);
201 }
202 });
203
204 // Tell the ASP.NET app what addresses we're listening on, so that it can proxy requests here
205 callback(null, {
206 Port: listener.address().port,
207 PublicPaths: normalizedPublicPaths,
208
209 // For back-compatibility with older versions of Microsoft.AspNetCore.SpaServices, in the case where
210 // you have exactly one webpackConfigArray entry. This will be removed soon.
211 PublicPath: normalizedPublicPaths[0]
212 });
213 } catch (ex) {
214 callback(ex.stack, null);
215 }
216 });
217}
218
219function removeTrailingSlash(str: string) {
220 if (str.lastIndexOf('/') === str.length - 1) {
221 str = str.substring(0, str.length - 1);
222 }
223
224 return str;
225}
226
227function getPath(publicPath: string) {
228 return url.parse(publicPath).path;
229}
230
231function firstIndexOfStringStartingWith(array: string[], prefixToFind: string) {
232 for (let index = 0; index < array.length; index++) {
233 const candidate = array[index];
234 if ((typeof candidate === 'string') && (candidate.substring(0, prefixToFind.length) === prefixToFind)) {
235 return index;
236 }
237 }
238
239 return -1; // Not found
240}
241
242function npmModuleIsPresent(moduleName: string) {
243 try {
244 require.resolve(moduleName);
245 return true;
246 } catch (ex) {
247 return false;
248 }
249}