1 | const path = require('path')
|
2 | const Event = require('events')
|
3 | const fs = require('fs-extra')
|
4 | const Config = require('webpack-chain')
|
5 | const express = require('express')
|
6 | const chalk = require('chalk')
|
7 | const chokidar = require('chokidar')
|
8 | const merge = require('lodash.merge')
|
9 | const { createBundleRenderer } = require('vue-server-renderer')
|
10 | const loadConfig = require('./utils/loadConfig')
|
11 | const basePlugin = require('./plugins/base')
|
12 | const logger = require('./logger')
|
13 | const serveStatic = require('./utils/serveStatic')
|
14 | const renderHtml = require('./utils/renderHtml')
|
15 | const minifyHtml = require('./utils/minifyHtml')
|
16 | const emoji = require('./emoji')
|
17 | const inspect = require('./utils/inspect')
|
18 | const validateConfig = require('./validateConfig')
|
19 |
|
20 | const runWebpack = compiler => {
|
21 | return new Promise((resolve, reject) => {
|
22 | compiler.run((err, stats) => {
|
23 | if (err) return reject(err)
|
24 | resolve(stats)
|
25 | })
|
26 | })
|
27 | }
|
28 |
|
29 | class Ream extends Event {
|
30 | constructor(options, config) {
|
31 | super()
|
32 | this.options = merge(
|
33 | {
|
34 | dev: process.env.NODE_ENV !== 'production',
|
35 | baseDir: '.',
|
36 | config: 'ream.config.js'
|
37 | },
|
38 | options
|
39 | )
|
40 |
|
41 |
|
42 | logger.setOptions(options)
|
43 |
|
44 |
|
45 | let userConfig
|
46 | if (this.options.config !== false) {
|
47 | const loadedConfig = loadConfig.loadSync(
|
48 | [this.options.config],
|
49 | this.options.baseDir
|
50 | )
|
51 | if (loadedConfig.path) {
|
52 | logger.debug('ream config', loadedConfig.path)
|
53 | userConfig = loadedConfig.data
|
54 | }
|
55 | }
|
56 | const validatedUserConfig = validateConfig(userConfig || {})
|
57 | if (validatedUserConfig.error) {
|
58 | throw validatedUserConfig.error
|
59 | }
|
60 |
|
61 | const defaults = {
|
62 | entry: 'index.js',
|
63 | outDir: '.ream',
|
64 | fsRoutes: false,
|
65 | transpileDependencies: [],
|
66 | runtimeCompiler: false,
|
67 | productionSourceMap: true,
|
68 | server: {
|
69 | port: process.env.PORT || 4000,
|
70 | host: process.env.HOST || '0.0.0.0'
|
71 | },
|
72 | plugins: [],
|
73 | generate: {
|
74 | routes: ['/']
|
75 | },
|
76 | css: {
|
77 | extract: !this.options.dev
|
78 | },
|
79 | pwa: false,
|
80 | minimize: !this.options.dev,
|
81 | minifyHtml: false
|
82 | }
|
83 |
|
84 |
|
85 | this.config = merge(defaults, validatedUserConfig.value, config)
|
86 |
|
87 | logger.debug('ream config', inspect(this.config))
|
88 |
|
89 | this.hooks = require('./hooks')
|
90 | this.configureServerFns = new Set()
|
91 | this.enhanceAppFiles = new Set()
|
92 | this.serverConfig = new Config()
|
93 | this.clientConfig = new Config()
|
94 | this.chainWebpackFns = []
|
95 |
|
96 | const projectPkg = loadConfig.loadSync(['package.json'])
|
97 | this.projectPkg = {
|
98 | data: projectPkg.data || {},
|
99 | path: projectPkg.path
|
100 | }
|
101 |
|
102 | this.loadPlugins()
|
103 | }
|
104 |
|
105 | chainWebpack(fn) {
|
106 | this.chainWebpackFns.push(fn)
|
107 | }
|
108 |
|
109 | addGenerateRoutes(routes) {
|
110 | this.config.generate.routes = this.config.generate.routes.concat(routes)
|
111 | return this
|
112 | }
|
113 |
|
114 | hasPlugin(name) {
|
115 | return this.plugins && this.plugins.find(plugin => plugin.name === name)
|
116 | }
|
117 |
|
118 | loadPlugins() {
|
119 | this.plugins = [
|
120 | basePlugin,
|
121 | require('./plugins/vuex'),
|
122 | require('./plugins/apollo'),
|
123 | require('./plugins/pwa'),
|
124 | require('./plugins/fs-routes')
|
125 | ]
|
126 | .concat(this.config.plugins)
|
127 | .filter(Boolean)
|
128 |
|
129 | this.plugins.forEach(plugin => plugin.apply(this))
|
130 | }
|
131 |
|
132 | createConfigs() {
|
133 | const getContext = type => ({
|
134 | isServer: type === 'server',
|
135 | isClient: type === 'client',
|
136 | dev: this.options.dev,
|
137 | type
|
138 | })
|
139 |
|
140 | if (this.config.chainWebpack) {
|
141 | this.chainWebpack(this.config.chainWebpack)
|
142 | }
|
143 | for (const fn of this.chainWebpackFns) {
|
144 | fn(this.serverConfig, getContext('server'))
|
145 | fn(this.clientConfig, getContext('client'))
|
146 | }
|
147 |
|
148 | if (this.options.inspectWebpack) {
|
149 | console.log('server config', chalk.dim(this.serverConfig.toString()))
|
150 | console.log('client config', chalk.dim(this.clientConfig.toString()))
|
151 | }
|
152 |
|
153 | let serverConfig = this.serverConfig.toConfig()
|
154 | let clientConfig = this.clientConfig.toConfig()
|
155 |
|
156 | const { configureWebpack } = this.config
|
157 | if (typeof configureWebpack === 'function') {
|
158 | serverConfig =
|
159 | configureWebpack(serverConfig, getContext('server')) || serverConfig
|
160 | clientConfig =
|
161 | configureWebpack(clientConfig, getContext('client')) || clientConfig
|
162 | }
|
163 |
|
164 | return [serverConfig, clientConfig]
|
165 | }
|
166 |
|
167 | createCompilers() {
|
168 | const webpack = require('webpack')
|
169 |
|
170 | return this.createConfigs().map(config => webpack(config))
|
171 | }
|
172 |
|
173 | async build() {
|
174 | await fs.remove(this.resolveOutDir())
|
175 | await this.prepareWebpack()
|
176 | const [serverCompiler, clientCompiler] = this.createCompilers()
|
177 | await Promise.all([runWebpack(serverCompiler), runWebpack(clientCompiler)])
|
178 | if (!this.isGenerating) {
|
179 | await this.hooks.run('onFinished', 'build')
|
180 | }
|
181 | }
|
182 |
|
183 | async generate(opts) {
|
184 | this.isGenerating = true
|
185 | await this.build()
|
186 | await this.generateOnly(opts)
|
187 | await this.hooks.run('onFinished', 'generate')
|
188 | }
|
189 |
|
190 | async generateOnly({ routes } = this.config.generate) {
|
191 | routes = [...new Set(routes)]
|
192 |
|
193 |
|
194 | this.prepareProduction({ serverType: 'generate' })
|
195 |
|
196 |
|
197 | if (await fs.pathExists('./public')) {
|
198 | await fs.copy('./public', this.resolveOutDir('generated'))
|
199 | }
|
200 |
|
201 |
|
202 | await fs.copy(
|
203 | this.resolveOutDir('client'),
|
204 | this.resolveOutDir('generated/_ream')
|
205 | )
|
206 |
|
207 |
|
208 | await fs.remove(this.resolveOutDir('generated/_ream/client-manifest.json'))
|
209 |
|
210 | await Promise.all(
|
211 | routes.map(async route => {
|
212 |
|
213 | const context = { req: { url: route } }
|
214 | const html = await this.renderHtml(context)
|
215 | const targetPath = this.resolveOutDir(
|
216 | `generated/${route.replace(/\/?$/, '/index.html')}`
|
217 | )
|
218 |
|
219 | logger.status(emoji.progress, `generating ${route}`)
|
220 |
|
221 | await fs.ensureDir(path.dirname(targetPath))
|
222 | await fs.writeFile(targetPath, html, 'utf8')
|
223 | })
|
224 | )
|
225 |
|
226 | logger.status(
|
227 | emoji.success,
|
228 | chalk.green(
|
229 | `Check out ${path.relative(
|
230 | process.cwd(),
|
231 | this.resolveOutDir('generated')
|
232 | )}`
|
233 | )
|
234 | )
|
235 | }
|
236 |
|
237 | configureServer(fn) {
|
238 | this.configureServerFns.add(fn)
|
239 | }
|
240 |
|
241 | async prepareFiles() {
|
242 | await fs.ensureDir(this.resolveOutDir())
|
243 | await Promise.all([
|
244 | this.writeEnhanceAppFile(),
|
245 | this.writeEntryFile(),
|
246 | this.hooks.run('onPrepareFiles')
|
247 | ])
|
248 | }
|
249 |
|
250 | writeEnhanceAppFile() {
|
251 | return fs.writeFile(
|
252 | this.resolveOutDir('enhance-app.js'),
|
253 | require('../app/enhance-app-template')(this)
|
254 | )
|
255 | }
|
256 |
|
257 | async writeEntryFile() {
|
258 | const writeFile = () =>
|
259 | fs.writeFile(
|
260 | this.resolveOutDir('entry.js'),
|
261 | require('../app/entry-template')(this)
|
262 | )
|
263 | writeFile()
|
264 | if (this.config.entry && this.options.dev) {
|
265 | chokidar
|
266 | .watch(this.resolveBaseDir(this.config.entry), {
|
267 | disableGlobbing: true,
|
268 | ignoreInitial: true
|
269 | })
|
270 | .on('add', writeFile)
|
271 | .on('unlink', writeFile)
|
272 | }
|
273 | }
|
274 |
|
275 | async getServer() {
|
276 | const server = express()
|
277 |
|
278 | for (const fn of this.configureServerFns) {
|
279 | fn(server)
|
280 | }
|
281 |
|
282 |
|
283 | server.use(require('compression')())
|
284 |
|
285 |
|
286 | server.use(serveStatic('public', !this.options.dev))
|
287 |
|
288 | if (this.options.dev) {
|
289 | await this.prepareWebpack()
|
290 | require('./utils/setupWebpackMiddlewares')(this)(server)
|
291 | } else {
|
292 | this.prepareProduction({ serverType: 'production' })
|
293 | server.get('/_ream/*', (req, res, ...args) => {
|
294 | req.url = req.url.replace(/^\/_ream/, '')
|
295 | serveStatic(
|
296 | this.resolveOutDir('client'),
|
297 | typeof req.shouldCache === 'boolean' ? req.shouldCache : true
|
298 | )(req, res, ...args)
|
299 | })
|
300 | }
|
301 |
|
302 | const handleError = fn => {
|
303 | return async (req, res) => {
|
304 | try {
|
305 | await fn(req, res)
|
306 | } catch (err) {
|
307 | if (err.name === 'ReamError') {
|
308 | if (err.code === 'REDIRECT') {
|
309 | res.writeHead(303, { Location: err.redirectURL })
|
310 | res.end()
|
311 | return
|
312 | }
|
313 | }
|
314 |
|
315 | res.status(500)
|
316 | if (this.options.dev) {
|
317 | res.end(err.stack)
|
318 | } else {
|
319 | res.end('server error')
|
320 | }
|
321 |
|
322 | console.log(err.stack)
|
323 | }
|
324 | }
|
325 | }
|
326 |
|
327 | server.get(
|
328 | '*',
|
329 | handleError(async (req, res) => {
|
330 | if (!this.renderer) {
|
331 | return res.end('Please wait for compilation...')
|
332 | }
|
333 |
|
334 | if (req.url.startsWith('/_ream/')) {
|
335 | res.status(404)
|
336 | return res.end('404')
|
337 | }
|
338 |
|
339 | const context = { req, res }
|
340 | const html = await this.renderHtml(context)
|
341 |
|
342 | res.setHeader('content-type', 'text/html')
|
343 | res.end(html)
|
344 | })
|
345 | )
|
346 |
|
347 | return server
|
348 | }
|
349 |
|
350 | async getRequestHandler() {
|
351 | const server = await this.getServer()
|
352 | return (req, res) => server(req, res)
|
353 | }
|
354 |
|
355 | async start() {
|
356 | const server = await this.getServer()
|
357 | return server.listen(this.config.server.port, this.config.server.host)
|
358 | }
|
359 |
|
360 | async prepareWebpack() {
|
361 | let postcssConfigFile
|
362 | if (this.projectPkg.path && this.projectPkg.data.postcss) {
|
363 | postcssConfigFile = this.projectPkg.path
|
364 | } else {
|
365 | const res = loadConfig.loadSync([
|
366 | 'postcss.config.js',
|
367 | '.postcssrc',
|
368 | '.postcssrc.js'
|
369 | ])
|
370 | postcssConfigFile = res.path
|
371 | }
|
372 |
|
373 | if (postcssConfigFile) {
|
374 | logger.debug('postcss config file', postcssConfigFile)
|
375 | this.config.postcss = {
|
376 | config: {
|
377 | path: postcssConfigFile
|
378 | }
|
379 | }
|
380 | }
|
381 |
|
382 | await this.prepareFiles()
|
383 | }
|
384 |
|
385 | prepareProduction({ serverType } = {}) {
|
386 | const serverBundle = JSON.parse(
|
387 | fs.readFileSync(this.resolveOutDir('server/server-bundle.json'), 'utf8')
|
388 | )
|
389 | const clientManifest = JSON.parse(
|
390 | fs.readFileSync(this.resolveOutDir('client/client-manifest.json'), 'utf8')
|
391 | )
|
392 |
|
393 | this.createRenderer({
|
394 | serverBundle,
|
395 | clientManifest,
|
396 | serverType
|
397 | })
|
398 | }
|
399 |
|
400 | createRenderer({ serverBundle, clientManifest, serverType }) {
|
401 | this.renderer = createBundleRenderer(serverBundle, {
|
402 | runInNewContext: false,
|
403 | clientManifest
|
404 | })
|
405 | this.emit('renderer-ready', serverType)
|
406 | }
|
407 |
|
408 | async renderHtml(context) {
|
409 | let html = await renderHtml(this.renderer, context)
|
410 | if (this.config.minifyHtml) {
|
411 | html = minifyHtml(html, this.config.minifyHtml)
|
412 | }
|
413 | return html
|
414 | }
|
415 |
|
416 | resolveOutDir(...args) {
|
417 | return this.resolveBaseDir(this.config.outDir, ...args)
|
418 | }
|
419 |
|
420 | resolveBaseDir(...args) {
|
421 | return path.resolve(this.options.baseDir, ...args)
|
422 | }
|
423 | }
|
424 |
|
425 | function ream(opts, config) {
|
426 | return new Ream(opts, config)
|
427 | }
|
428 |
|
429 | module.exports = ream
|
430 | module.exports.Ream = Ream
|