UNPKG

23.3 kBJavaScriptView Raw
1import path from 'path'
2import fs from 'fs'
3
4import require_hacker from 'require-hacker'
5import UglifyJS from 'uglify-js'
6
7import Log from './tools/log'
8import request from './tools/synchronous http'
9
10import { exists, clone, convert_from_camel_case, starts_with, ends_with, alias_properties_with_camel_case } from './helpers'
11import { default_webpack_assets, normalize_options, alias_hook, normalize_asset_path, uniform_path } from './common'
12
13// using ES6 template strings
14// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/template_strings
15export default class webpack_isomorphic_tools
16{
17 // require() hooks for assets
18 hooks = []
19
20 // used to keep track of cached assets and flush their caches on .refresh() call
21 cached_assets = []
22
23 constructor(options)
24 {
25 // take the passed in options
26 this.options = convert_from_camel_case(clone(options))
27
28 // add missing fields, etc
29 normalize_options(this.options)
30
31 // set development mode flag
32 this.options.development = process.env.NODE_ENV !== 'production'
33
34 // set require-hacker debug mode if run in debug mode
35 if (this.options.debug)
36 {
37 require_hacker.log.options.debug = true
38 }
39
40 // logging
41 this.log = new Log('webpack-isomorphic-tools', { debug: this.options.debug })
42
43 this.log.debug(`instantiated webpack-isomorphic-tools v${require('../package.json').version} with options`, this.options)
44 }
45
46 // (deprecated)
47 // sets development mode flag to whatever was passed (or true if nothing was passed)
48 // (development mode allows asset hot reloading when used with webpack-dev-server)
49 development()
50 {
51 // display deprecation notice
52 this.log.error('`.development()` method is now deprecated ' +
53 '(for server-side instance only, not for webpack plugin instance) ' +
54 'and has no effect. Set up a proper `process.env.NODE_ENV` variable instead: ' +
55 'it should be "production" for production, otherwise it assumes development. ' +
56 'The currently used mode is: ' + (this.options.development ? 'development' : 'production') + '. ' +
57 '`process.env.NODE_ENV is: ' + process.env.NODE_ENV)
58
59 // allows method chaining
60 return this
61 }
62
63 // returns a mapping to read file paths for all the user specified asset types
64 // along with a couple of predefined ones: javascripts and styles
65 assets()
66 {
67 // when in development mode
68 if (this.options.development)
69 {
70 // webpack and node.js start in parallel
71 // so webpack-assets.json might not exist on the very first run
72 // if a developer chose not to use the .server() method with a callback
73 // (or if a developer chose not to wait for a Promise returned by the .server() method)
74
75 // either go over a network
76 if (this.options.port)
77 {
78 try
79 {
80 return request(this.options.port)
81 }
82 catch (error)
83 {
84 this.log.error(`Couldn't contact webpack-isomorphic-tools plugin over HTTP. Using an empty stub for webpack assets map.`)
85 this.log.error(error)
86 return default_webpack_assets()
87 }
88 }
89 // or read it from disk
90 else
91 {
92 if (!fs.existsSync(this.webpack_assets_path))
93 {
94 this.log.error(`"${this.webpack_assets_path}" not found. Most likely it hasn't yet been generated by Webpack. The most probable cause of this error is that you placed your server code outside of the callback in "webpack_isomorphic_tools.server(path, callback)" (or outside of the ".then()" call if you are using promises API). Using an empty stub instead.`)
95 return default_webpack_assets()
96 }
97 }
98 }
99
100 // sanity check
101 if (!this.webpack_assets_path)
102 {
103 throw new Error(`You seem to have forgotten to call the .server() method`)
104 }
105
106 return require(this.webpack_assets_path)
107 }
108
109 // clear the require.cache (only used in developer mode with webpack-dev-server)
110 refresh()
111 {
112 // ensure this is development mode
113 if (!this.options.development)
114 {
115 throw new Error('.refresh() called in production mode. It shouldn\'t be called in production mode because that would degrade website performance by discarding caches.')
116 }
117
118 this.log.debug('flushing require() caches')
119
120 // uncache webpack-assets.json file
121 // this.log.debug(' flushing require() cache for webpack assets json file')
122 // this.log.debug(` (was cached: ${typeof(require.cache[this.webpack_assets_path]) !== 'undefined'})`)
123 delete require.cache[this.webpack_assets_path]
124
125 // uncache cached assets
126 for (let path of this.cached_assets)
127 {
128 this.log.debug(` flushing require() cache for ${path}`)
129 delete require.cache[path]
130 }
131
132 // no assets are cached now
133 this.cached_assets = []
134 }
135
136 // Makes `webpack-isomorphic-tools` aware of Webpack aliasing feature.
137 // https://webpack.github.io/docs/resolving.html#aliasing
138 // The `aliases` parameter corresponds to `resolve.alias`
139 // in your Webpack configuration.
140 // If this method is used it must be called before the `.server()` method.
141 enable_aliasing()
142 {
143 // mount require() hook
144 this.alias_hook = require_hacker.resolver((path, module) =>
145 {
146 // returns aliased global filesystem path
147 return alias_hook(path, module, this.options.project_path, this.options.alias, this.log)
148 })
149
150 // allows method chaining
151 return this
152 }
153
154 // Initializes server-side instance of `webpack-isomorphic-tools`
155 // with the base path for your project, then calls `.register()`,
156 // and after that calls .wait_for_assets(callback).
157 //
158 // The `project_path` parameter must be identical
159 // to the `context` parameter of your Webpack configuration
160 // and is needed to locate `webpack-assets.json`
161 // which is output by Webpack process.
162 //
163 // sets up "project_path" option
164 // (this option is required on the server to locate webpack-assets.json)
165 server(project_path, callback)
166 {
167 // project base path, required to locate webpack-assets.json
168 this.options.project_path = project_path
169
170 // resolve webpack-assets.json file path
171 this.webpack_assets_path = path.resolve(this.options.project_path, this.options.webpack_assets_file_path)
172
173 // register require() hooks
174 this.register()
175
176 // if Webpack aliases are supplied, enable aliasing
177 if (this.options.alias)
178 {
179 this.enable_aliasing()
180 }
181
182 // if Webpack `modulesDirectories` are supplied, enable them
183 if (this.options.modules_directories)
184 {
185 this.inject_modules_directories(this.options.modules_directories)
186 }
187
188 // inject helpers like require.context() and require.ensure()
189 if (this.options.patch_require)
190 {
191 this.log.debug('Patching Node.js require() function')
192 this.patch_require()
193 }
194
195 // when ready:
196
197 // if callback is given, call it back
198 if (callback)
199 {
200 // call back when ready
201 return this.wait_for_assets(callback)
202 }
203 // otherwise resolve a Promise
204 else
205 {
206 // no callback given, return a Promise
207 return new Promise((resolve, reject) => this.wait_for_assets(resolve))
208 }
209 }
210
211 // Registers Node.js require() hooks for the assets
212 //
213 // This is what makes the `requre()` magic work on server.
214 // These `require()` hooks must be set before you `require()`
215 // any of your assets
216 // (e.g. before you `require()` any React components
217 // `require()`ing your assets).
218 //
219 // read this article if you don't know what a "require hook" is
220 // http://bahmutov.calepin.co/hooking-into-node-loader-for-fun-and-profit.html
221 register()
222 {
223 this.log.debug('registering require() hooks for assets')
224
225 // // a helper array for extension matching
226 // const extensions = []
227 //
228 // // for each user specified asset type,
229 // // for each file extension,
230 // // create an entry in the extension matching array
231 // for (let asset_type of Object.keys(this.options.assets))
232 // {
233 // const description = this.options.assets[asset_type]
234 //
235 // for (let extension of description.extensions)
236 // {
237 // extensions.push([`.${extension}`, description])
238 // }
239 // }
240 //
241 // // registers a global require() hook which runs
242 // // before the default Node.js require() logic
243 // this.asset_hook = require_hacker.global_hook('webpack-asset', (path, module) =>
244 // {
245 // // for each asset file extension
246 // for (let extension of extensions)
247 // {
248 // // if the require()d path has this file extension
249 // if (ends_with(path, extension[0]))
250 // {
251 // // then require() it using webpack-assets.json
252 // return this.require(require_hacker.resolve(path, module), extension[1])
253 // }
254 // }
255 // })
256
257 // for each user specified asset type,
258 // register a require() hook for each file extension of this asset type
259 for (let asset_type of Object.keys(this.options.assets))
260 {
261 const description = this.options.assets[asset_type]
262
263 for (let extension of description.extensions)
264 {
265 this.register_extension(extension, description)
266 }
267 }
268
269 // intercepts loader-powered require() paths
270 this.loaders_hook = require_hacker.global_hook('webpack-loaders', (required_path, module) =>
271 {
272 // filter out non-loader paths
273 // (ignore filesystem paths (both Linux and Windows)
274 // and non-loader paths)
275 if (starts_with(required_path, '/')
276 || starts_with(required_path, './')
277 || starts_with(required_path, '../')
278 || required_path.indexOf(':') > 0
279 || required_path.indexOf('!') < 0)
280 {
281 return
282 }
283
284 let parts = required_path.split('!')
285 const local_asset_path = parts.pop()
286
287 // extra measures taken here to not
288 // confuse some legit require()d path
289 // with a seemingly loader-powered one
290 if (!starts_with(local_asset_path, './')
291 && !starts_with(local_asset_path, '../'))
292 {
293 return
294 }
295
296 parts = parts.map(loader =>
297 {
298 let loader_parts = loader.split('?')
299
300 if (!ends_with(loader_parts[0], '-loader'))
301 {
302 loader_parts[0] += '-loader'
303 }
304
305 return `./~/${loader_parts.join('?')}`
306 })
307
308 const global_asset_path = require_hacker.resolve(local_asset_path, module)
309
310 const path = parts.join('!') + '!' + this.normalize_asset_path(global_asset_path)
311
312 const asset = this.asset_source(path)
313
314 if (asset === undefined)
315 {
316 return
317 }
318
319 return this.require_asset(asset, { require_cache_path: required_path + '.webpack-loaders' })
320 })
321
322 // allows method chaining
323 return this
324 }
325
326 // registers a require hook for a particular file extension
327 register_extension(extension, description)
328 {
329 this.log.debug(` registering a require() hook for *.${extension}`)
330
331 // place the require() hook for this extension
332 if (extension === 'json')
333 {
334 this.hooks.push(require_hacker.hook(extension, path =>
335 {
336 // special case for require('webpack-assets.json') and 'json' asset extension
337 if (path === this.webpack_assets_path)
338 {
339 return
340 }
341
342 return this.require(path, description)
343 }))
344 }
345 else
346 {
347 this.hooks.push(require_hacker.hook(extension, path => this.require(path, description)))
348 }
349 }
350
351 // injects Webpack's `modulesDirectories` into Node.js module resolver
352 inject_modules_directories(modules_directories)
353 {
354 modules_directories = modules_directories.filter(x => x !== 'node_modules')
355
356 // instrument Module._nodeModulePaths function
357 // https://github.com/nodejs/node/blob/master/lib/module.js#L202
358 //
359 const original_find_paths = require('module')._findPath
360 //
361 require('module')._findPath = function(request, paths)
362 {
363 paths.map(function(a_path)
364 {
365 var parts = a_path.split(path.sep)
366 if (parts[parts.length - 1] === 'node_modules')
367 {
368 parts[parts.length - 1] = ''
369 return parts.join(path.sep)
370 }
371 })
372 .filter(function(a_path)
373 {
374 return a_path
375 })
376 .forEach(function(a_path)
377 {
378 modules_directories.forEach(function(modules_directory)
379 {
380 paths.push(a_path + modules_directory)
381 })
382 })
383
384 return original_find_paths(request, paths)
385 }
386 }
387
388 // injects helper functions into `require()` function
389 // (such as `.context()` and `.ensure()`)
390 // https://github.com/halt-hammerzeit/webpack-isomorphic-tools/issues/48#issuecomment-182878437
391 // (this is a "dirty" way to do it but it works)
392 patch_require()
393 {
394 // a source code of a function that
395 // require()s all modules inside the `base` folder
396 // and puts them into a hash map for further reference
397 //
398 // https://webpack.github.io/docs/context.html
399 //
400 let require_context = `require.context = function(base, scan_subdirectories, regular_expression)
401 {
402 base = require('path').join(require('path').dirname(module.filename), base)
403
404 var contents = {}
405
406 // recursive function
407 function read_directory(directory)
408 {
409 require('fs').readdirSync(directory).forEach(function(child)
410 {
411 var full_path = require('path').resolve(directory, child)
412
413 if (require('fs').statSync(full_path).isDirectory())
414 {
415 if (scan_subdirectories)
416 {
417 read_directory(full_path)
418 }
419 }
420 else
421 {
422 var asset_path = require('path').relative(base, full_path)
423
424 // analogous to "uniform_path" from "./common.js"
425 asset_path = (asset_path[0] === '.' ? asset_path : ('./' + asset_path)).replace(/\\\\/g, '/')
426
427 if (regular_expression && !regular_expression.test(asset_path))
428 {
429 return
430 }
431
432 contents[asset_path] = full_path
433 }
434 })
435 }
436
437 read_directory(base)
438
439 var result = function(asset_path)
440 {
441 return require(contents[asset_path])
442 }
443
444 result.keys = function()
445 {
446 return Object.keys(contents)
447 }
448
449 result.resolve = function(asset_path)
450 {
451 return contents[asset_path]
452 }
453
454 return result
455 };`
456
457 // some code minification
458 require_context = UglifyJS.minify(require_context, { fromString: true }).code
459
460 // Source code for `require.ensure()`
461 // https://github.com/halt-hammerzeit/webpack-isomorphic-tools/issues/84
462 const require_ensure = `require.ensure=function(d,c){c(require)};`
463
464 const debug = this.log.debug.bind(this.log)
465
466 // instrument Module.prototype._compile function
467 // https://github.com/nodejs/node/blob/master/lib/module.js#L376-L380
468 //
469 const original_compile = require('module').prototype._compile
470 //
471 require('module').prototype._compile = function(content, filename)
472 {
473 // inject it only in .js files
474 if (!ends_with(filename, '.js'))
475 {
476 // (the return value is supposed to be `undefined`)
477 return original_compile.call(this, content, filename)
478 }
479
480 // will be prepended to the module source code
481 let preamble = ''
482
483 // inject it only in .js files which
484 // might probably have `require.context` reference
485 if (content.indexOf('require.context') >= 0)
486 {
487 debug(`Injecting require.context() into "${filename}"`)
488 preamble += require_context
489 }
490
491 // inject it only in .js files which
492 // might probably have `require.ensure` reference
493 if (content.indexOf('require.ensure') >= 0)
494 {
495 debug(`Injecting require.ensure() into "${filename}"`)
496 preamble += require_ensure
497 }
498
499 // If there is a preamble to prepend
500 if (preamble)
501 {
502 // Account for "use strict" which is required to be in the beginning of the source code
503 if (starts_with(content, `'use strict'`) || starts_with(content, `"use strict"`))
504 {
505 preamble = `"use strict";` + preamble
506 }
507 }
508
509 // the "dirty" way
510 content = preamble + content
511
512 // (the return value is supposed to be `undefined`)
513 return original_compile.call(this, content, filename)
514 }
515 }
516
517 normalize_asset_path(global_asset_path)
518 {
519 // sanity check
520 /* istanbul ignore if */
521 if (!this.options.project_path)
522 {
523 throw new Error(`You forgot to call the .server() method passing it your project's base path`)
524 }
525
526 // convert global asset path to local-to-the-project asset path
527 return normalize_asset_path(global_asset_path, this.options.project_path)
528 }
529
530 // require()s an asset by a global path
531 require(global_asset_path, description)
532 {
533 this.log.debug(`require() called for ${global_asset_path}`)
534
535 // convert global asset path to local-to-the-project asset path
536 const asset_path = this.normalize_asset_path(global_asset_path)
537
538 // if this filename is in the user specified exceptions list
539 // (or is not in the user explicitly specified inclusion list)
540 // then fall back to the normal require() behaviour
541 if (!this.includes(asset_path, description) || this.excludes(asset_path, description))
542 {
543 this.log.debug(` skipping require call for ${asset_path}`)
544 return
545 }
546
547 // find this asset in the list
548 const asset = this.asset_source(asset_path)
549
550 // if the asset was not found in the list, output an error
551 if (asset === undefined)
552 {
553 this.log.error(`asset not found: ${asset_path}`)
554 }
555
556 return this.require_asset(asset, { require_cache_path: global_asset_path })
557 }
558
559 // require()s an asset by it source
560 require_asset(asset, options)
561 {
562 // this.log.debug(`require() called for ${asset_path}`)
563
564 // track cached assets (only in development mode)
565 if (this.options.development)
566 {
567 // mark this asset as cached
568 this.cached_assets.push(options.require_cache_path)
569 }
570
571 // return CommonJS module source for this asset
572 return require_hacker.to_javascript_module_source(asset)
573 }
574
575 // returns asset source by path (looks it up in webpack-assets.json)
576 asset_source(asset_path)
577 {
578 this.log.debug(` requiring ${asset_path}`)
579
580 // Webpack replaces `node_modules` with `~`.
581 // I don't know how exactly it decides whether to
582 // replace `node_modules` with `~` or not
583 // so it will be a guess.
584 function possible_webpack_paths(asset_path)
585 {
586 // Webpack always replaces project's own `node_modules` with `~`
587 if (starts_with(asset_path, './node_modules/'))
588 {
589 asset_path = asset_path.replace('./node_modules/', './~/')
590 }
591
592 // if there are any `node_modules` left,
593 // supposing the count is N,
594 // then there are 2 to the power of N possible guesses
595 // on how webpack path might look like.
596 const parts = asset_path.split('/node_modules/')
597
598 function construct_guesses(parts)
599 {
600 if (parts.length === 1)
601 {
602 return [parts]
603 }
604
605 const last = parts.pop()
606 const rest = construct_guesses(parts)
607
608 const guesses = []
609
610 for (let guess of rest)
611 {
612 const one = clone(guess)
613 one.push('/~/')
614 one.push(last)
615
616 const two = clone(guess)
617 two.push('/node_modules/')
618 two.push(last)
619
620 guesses.push(one)
621 guesses.push(two)
622 }
623
624 return guesses
625 }
626
627 return construct_guesses(parts)
628 }
629
630 // get real file path list
631 const assets = this.assets().assets
632
633 const possible_webpack_asset_paths = possible_webpack_paths(asset_path).map(path => path.join(''))
634
635 for (let webpack_asset_path of possible_webpack_asset_paths)
636 {
637 if (possible_webpack_asset_paths.length > 1)
638 {
639 this.log.debug(` trying "${webpack_asset_path}"`)
640 }
641
642 // find this asset in the real file path list
643 const asset = assets[webpack_asset_path]
644
645 if (exists(asset))
646 {
647 // the asset was found in the list - return it
648 return asset
649 }
650 }
651
652 // if the asset was not found in the list, return nothing
653 return
654 }
655
656 // unregisters require() hooks
657 undo()
658 {
659 // for each user specified asset type,
660 // unregister a require() hook for each file extension of this asset type
661 for (let hook of this.hooks)
662 {
663 hook.unmount()
664 }
665
666 // this.asset_hook.unmount()
667
668 // unmount the aliasing hook (if mounted)
669 if (this.alias_hook)
670 {
671 this.alias_hook.unmount()
672 }
673
674 // unmount require() hook which intercepts loader-powered require() paths
675 if (this.loaders_hook)
676 {
677 this.loaders_hook.unmount()
678 }
679 }
680
681 // Checks if the required path should be excluded from the custom require() hook
682 excludes(path, options)
683 {
684 // if "exclude" parameter isn't specified, then exclude nothing
685 if (!exists(options.exclude))
686 {
687 return false
688 }
689
690 // for each exclusion case
691 for (let exclude of options.exclude)
692 {
693 // supports regular expressions
694 if (exclude instanceof RegExp)
695 {
696 if (exclude.test(path))
697 {
698 return true
699 }
700 }
701 // check for a compex logic match
702 else if (typeof exclude === 'function')
703 {
704 if (exclude(path))
705 {
706 return true
707 }
708 }
709 // otherwise check for a simple textual match
710 else
711 {
712 if (exclude === path)
713 {
714 return true
715 }
716 }
717 }
718
719 // no matches found.
720 // returns false so that it isn't undefined (for testing purpose)
721 return false
722 }
723
724 // Checks if the required path should be included in the custom require() hook
725 includes(path, options)
726 {
727 // if "include" parameter isn't specified, then include everything
728 if (!exists(options.include))
729 {
730 return true
731 }
732
733 // for each inclusion case
734 for (let include of options.include)
735 {
736 // supports regular expressions
737 if (include instanceof RegExp)
738 {
739 if (include.test(path))
740 {
741 return true
742 }
743 }
744 // check for a compex logic match
745 else if (typeof include === 'function')
746 {
747 if (include(path))
748 {
749 return true
750 }
751 }
752 // otherwise check for a simple textual match
753 else
754 {
755 if (include === path)
756 {
757 return true
758 }
759 }
760 }
761
762 // no matches found.
763 // returns false so that it isn't undefined (for testing purpose)
764 return false
765 }
766
767 // Waits for webpack-assets.json to be created after Webpack build process finishes
768 //
769 // The callback is called when `webpack-assets.json` has been found
770 // (it's needed for development because `webpack-dev-server`
771 // and your application server are usually run in parallel).
772 //
773 wait_for_assets(done)
774 {
775 // condition check interval
776 const check_interval = 300 // in milliseconds
777 const message_interval = 2000 // in milliseconds
778
779 // show the message not too often
780 let message_timer = 0
781
782 // selfie
783 const tools = this
784
785 // waits for condition to be met, then proceeds
786 function wait_for(condition, proceed)
787 {
788 function check()
789 {
790 // if the condition is met, then proceed
791 if (condition())
792 {
793 return proceed()
794 }
795
796 message_timer += check_interval
797
798 if (message_timer >= message_interval)
799 {
800 message_timer = 0
801
802 tools.log.debug(`(${tools.webpack_assets_path} not found)`)
803 tools.log.info('(waiting for the first Webpack build to finish)')
804 }
805
806 setTimeout(check, check_interval)
807 }
808
809 check()
810 }
811
812 // wait for webpack-assets.json to be written to disk by Webpack
813 // (setTimeout() for global.webpack_isomorphic_tools )
814
815 let ready_check
816
817 // either go over network
818 if (this.options.development && this.options.port)
819 {
820 ready_check = () =>
821 {
822 try
823 {
824 request(this.options.port)
825 return true
826 }
827 catch (error)
828 {
829 if (!starts_with(error.message, 'Server responded with status code 404:\nWebpack assets not generated yet')
830 && !starts_with(error.message, 'connect ECONNREFUSED')
831 && !starts_with(error.message, 'Request timed out after'))
832 {
833 this.log.error(`Couldn't contact webpack-isomorphic-tools plugin over HTTP. Using an empty stub for webpack assets map.`)
834 this.log.error(error)
835 }
836
837 return false
838 }
839 }
840 }
841 // or read it from disk
842 else
843 {
844 ready_check = () => fs.existsSync(this.webpack_assets_path)
845 }
846
847 setImmediate(() => wait_for(ready_check, done))
848
849 // allows method chaining
850 return this
851 }
852}
853
854// Doesn't work with Babel 6 compiler
855// // alias camel case for those who prefer it
856// alias_properties_with_camel_case(webpack_isomorphic_tools.prototype)
\No newline at end of file