UNPKG

11.1 kBJavaScriptView Raw
1const path = require('path')
2const Event = require('events')
3const fs = require('fs-extra')
4const Config = require('webpack-chain')
5const express = require('express')
6const chalk = require('chalk')
7const chokidar = require('chokidar')
8const merge = require('lodash.merge')
9const { createBundleRenderer } = require('vue-server-renderer')
10const loadConfig = require('./utils/loadConfig')
11const basePlugin = require('./plugins/base')
12const logger = require('./logger')
13const serveStatic = require('./utils/serveStatic')
14const renderHtml = require('./utils/renderHtml')
15const minifyHtml = require('./utils/minifyHtml')
16const emoji = require('./emoji')
17const inspect = require('./utils/inspect')
18const validateConfig = require('./validateConfig')
19
20const 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
29class 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 // Init logger options
42 logger.setOptions(options)
43
44 // Load ream config
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 // config from constructor can override user config
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 // Not an actually server
193 // Don't emit `server-ready` event
194 this.prepareProduction({ serverType: 'generate' })
195
196 // Copy public folder to root path of `generated`
197 if (await fs.pathExists('./public')) {
198 await fs.copy('./public', this.resolveOutDir('generated'))
199 }
200
201 // Copy webpack assets to `generated`
202 await fs.copy(
203 this.resolveOutDir('client'),
204 this.resolveOutDir('generated/_ream')
205 )
206
207 // Remove unnecessary files
208 await fs.remove(this.resolveOutDir('generated/_ream/client-manifest.json'))
209
210 await Promise.all(
211 routes.map(async route => {
212 // Fake req
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 // Compress
283 server.use(require('compression')())
284
285 // Serve ./public folder at root path
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 // Cache
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
425function ream(opts, config) {
426 return new Ream(opts, config)
427}
428
429module.exports = ream
430module.exports.Ream = Ream